Skip to content

[slice-acl] Runtime range access control for BStack and BStackOwnedSlice - #74

Open
williamwutq wants to merge 20 commits into
masterfrom
expensive-slice-access-control
Open

williamwutq wants to merge 20 commits into
masterfrom
expensive-slice-access-control

Conversation

@williamwutq

Copy link
Copy Markdown
Owner

Description: Adds runtime range access control to BStack and BStackOwnedSlice. A per-stack PointTable (sorted, coalesced change-points) records an access mode over every byte offset, and every I/O path consults it before touching payload. Modes span All (transparent), Rw/RwStrict, Prot/RwProt, ReadOnly, Locked, and Alloc. Protection is minted through one-shot capability tokens — BStackProtection (guard authority) and BStackAllocAuthority (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: the acl_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 engines process_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 header Alloc via "run generator as allocator" — sibling _as engines duplicated into acl.rs that present BStackAccessAuthorities::ALLOC through the same crash-atomic path, so the header mark is never lifted mid-flight and concurrency stays sound under the atomic feature. dealloc is refuse-if-protected: freeing a range that still carries any caller mode outside {All, Alloc} returns PermissionDenied (the caller must un-protect first); the allocator only ever clears its own Alloc marks, 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

williamwutq and others added 6 commits September 4, 2026 00:09
…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>
Comment thread src/acl_core.rs Dismissed
Comment thread src/lib.rs Fixed
williamwutq and others added 5 commits September 5, 2026 01:35
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>
@williamwutq
williamwutq force-pushed the expensive-slice-access-control branch from f4e58b1 to a88a9ff Compare September 9, 2026 02:15
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 williamwutq left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rearchitecting needed

Comment thread src/acl.rs Outdated
Comment thread src/acl.rs Outdated
Comment thread src/acl.rs Outdated
Comment thread src/acl.rs Outdated
Comment thread src/acl.rs Outdated
Comment thread src/acl_core.rs Outdated
Comment thread src/lib.rs Outdated
Comment thread src/lib.rs Outdated
Comment thread src/alloc/slice.rs
Comment thread src/alloc/slice.rs Outdated
williamwutq and others added 7 commits September 9, 2026 00:16
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>

@williamwutq williamwutq left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes needed

Comment thread src/alloc/checked_slab.rs Outdated
#[cfg(feature = "expensive-slice-access-control")]
alloc_auth: stack
.take_alloc_authority()
.expect("fresh stack owns its alloc permit"),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new returns io::Result<Self>, and returning io::Error with PermissionDenied is better here

Comment thread src/alloc/checked_slab.rs Outdated
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)?;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need a "read_u64" helper

Comment thread src/alloc/checked_slab.rs Outdated
Comment on lines +1590 to +1592
// 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();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/alloc/first_fit.rs Outdated
{
// 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();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See previous comment on calling self.stack.acl_reset() and why it is not necessary

Comment thread src/alloc/slice.rs Outdated
Comment on lines 792 to 804
/// 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)
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have no need for these wrappers

Comment thread src/alloc/slice.rs Outdated
Comment on lines +832 to +846
// 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);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants