The family-wide trust model — zones, the containment law, threading by
claim, validation duties, exhaustion vectors, WASI policy, and non-goals —
is specified once in the
interop security model;
this document instantiates it for liblevenshtein's own surfaces and
cites it rather than restating it. The project-wide threat model for
non-binding surfaces (serialization, .llre parsing, resource ceilings)
remains docs/SECURITY.md; this page covers what is
specific to consuming foreign dictionary resources and exporting the
llev_* C ABI.
liblevenshtein's position in the zone map: its llev_* entry points are
trusted code receiving hostile inputs (from the host application), and
its resource consumer receives untrusted callback outputs (from
whatever stands behind a VtResource) — a buggy sibling, a version-skewed
sibling, or an adversary; the same validation defends against all three.
llev_* gateEvery fallible llev_* entry point executes inside boundary()
(src/ffi/index.rs:124-148): catch_unwind around the whole operation, a
caught panic downcast to its message and surfaced as LLEV_STATUS_PANIC,
success clearing the per-thread error slot, failure storing the message.
Nothing unwinds across the ABI in either direction — the family law
(canon § 3),
contract row ffi-boundary-panic-containment in
UNSAFE_ABI_CONTRACTS.tsv.
The symmetric duty: callers' extern "C" callbacks (the batch reducer)
must not unwind into the library — a reducer signals failure through its
return status, which llev_query_cursor_reduce forwards verbatim
(C-ABI reference § 7.6).
LAST_ERROR (src/ffi/index.rs) is thread-local, library-owned, and
NUL-sanitized; llev_last_error_message hands out a borrowed pointer the
caller never frees. Capacity discipline: one message per thread, plus
one latched BindingError per provider on the fault channel
(src/bindings.rs:121, first fault wins) — constant space per thread and
per provider by construction, however chatty a hostile provider gets
(canon § 6, "message-channel flooding"). Contract row
ffi-last-error-thread-locality.
Landed hardening (this wave). Interop callbacks return their status as
a raw u32 on the Rust side; the consumer decodes at the single
status() chokepoint via VtStatus::from_raw, and an out-of-range value
becomes BindingError::InvalidProviderOutput — provider misbehavior, never
undefined behavior. This closed the highest-severity consumer finding,
LLEV-B6 (commit e42485c, family-wide;
the round-trip bijection over 0..=8 is pinned in
vinary-tree-interop/tests/discriminant_pins.rs). The C header is
byte-identical — C enums are integer-typed; only Rust needed the rule.
Contract row ffi-callback-status-trust states the decode-before-use duty.
The analogous project-level wire — a reducer callback's LlevStatus
return — carries the documented duty that the callback return one of the
13 published values; the abort channel forwards it verbatim.
The C ABI's batch views are borrowed pointers into cursor-owned arenas — exactly the shape use-after-free exploits love. The lease machine removes every path to a dangling read by refusal, not by caller discipline:
llev_query_cursor_free
on a leased cursor returns LLEV_STATUS_BATCH_IN_USE and the cursor —
and therefore every borrowed pointer — stays alive and caller-owned.
Freeing storage the caller still borrows is the use-after-free factory
this rule exists to shut (contract row ffi-handle-box-round-trip).next_batch/reduce return
BATCH_IN_USE while a lease is live, so the library never overwrites
arenas a caller is reading (ffi-leased-batch-aliasing: no mutation, no
reallocation during the borrow window; the two-pass pointer fixup makes
realloc-dangling structurally impossible —
resource-consumer § 7).INVALID_ARGUMENT and changes
nothing. A confused caller cannot "accidentally" release a newer lease
with an older token and then read freed memory.LlevBatchLease.tla (LLEV-LEASE-1..7, this wave), with the
Verus arena obligations (LLEV-ARENA-1..3) covering the fixup window;
anchor tests in
tests/ffi_resource_snapshot_semantics.rs.The canon's duty table (§ 5) lands in this consumer as follows — the full mechanism-by-mechanism walkthrough is resource-consumer.md:
| Hostile input | This consumer's response | State |
|---|---|---|
| Out-of-range status code | refused at the decode chokepoint (§ 3) | landed (LLEV-B6 fixed) |
Malformed negotiation output (null vtable on success, short struct_size, wrong abi_version, nonzero base reserved) | IncompatibleResourceAbi / InvalidProviderOutput at intake; the fresh retain is released on every rejection path | landed (W1 baseline) |
| Snapshot that is null or changes unit/value domain | InvalidProviderOutput; a revision cannot change what its labels mean | landed |
| Out-of-domain edge label | rejected, never truncated (truncation aliases labels) | landed |
Non-boolean flag bytes (is_final, found, has_value outside zero/one) | InvalidProviderOutput | landed |
Page-length lies (written > capacity, shrinking totals, no-progress pages) | rejected by the paging acceptance checks; the three family consumers' predicates are being harmonized to the single proven ConsumerAcceptance.v predicate this wave — LLEV-B8 / F3 | wave-W3 hardening |
Nonzero VtOptionalU64.reserved | validated as zero from this wave — LLEV-B7 / F2, aligning with lling-llang's existing arc-reserved check under VT-ABI-5 | wave-W3 hardening |
End from an interface callback | treated as a provider error (family pin F5) | landed |
Residual exhaustion note (documented current state). The canon's
inflated-out_total vector prescribes never sizing allocations from a
provider-claimed total. The consumer pages through fixed 256-entry buffers
as prescribed, but its edge expansion does pre-reserve its result vector
from the claimed remaining total once pages validate; a fabricated total
therefore upgrades from wasted paging into an allocation fault — a caught
capacity overflow panic (surfacing as PANIC) or, in a magnitude band
below isize::MAX, an allocator abort. Memory safety is not at stake;
availability is. This is flagged alongside the wave-W3 paging-acceptance
harmonization (the acceptance predicate is where a claimed-total sanity
bound belongs) rather than silently absorbed into it.
Serialization is per captured provider object (VT-GATE-1..3; resource-consumer § 4). The security-relevant corollaries:
PARALLEL_REENTRANT races only its own
state; whatever garbage the races produce re-enters through § 5's
validation (canon § 4 carries the full argument);The WASI capability policy (preopens only; persistent backends only at
preopened paths in wasi-feature builds; trap containment at the instance
boundary) is canon § 7, instantiated for this repo's umbrella runtime in
wasm-topology § 5-6
— including the two open, this-wave hardening items on that surface:
panic-class sites in the runtime crate
(LLEV-B4) and the N-API
declared-but-unbound symbols (LLEV-B12).
Everything in canon § 8 applies verbatim — no in-process memory-safety
guarantee against a hostile native provider (RustBelt scopes what safe
Rust can promise: the consumer adds no unsafety of its own, per the
contract rows in
UNSAFE_ABI_CONTRACTS.tsv;
it cannot police its neighbor's stores), no confidentiality inside the
process, no liveness against a stalled callback, isolation only by
deployment choice. The one llev-specific addition: the distance functions
and string helpers are pure and stateless, so their entire threat surface
is argument validation (sentinel-coded, C-ABI reference § 6).
Canonical upstream: family security model · project threat model · UNSAFE_ABI_CONTRACTS.tsv · findings ledger. Siblings: libdictenstein FFI boundary · lling-llang ABI trust model · duallity threat model.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |