invariant / 2
141 remembered, 83 forgotten in this chunk.
invariant-le-wide-symbolic-byte-layout
forgotten
When a wide symbolic value is stored to LE memory (Endness::Little) via store_symbolic / store_concrete in native/angr/src/memory.rs, the symbolic_objects map keeps the BVS as-is at base addr. The implicit byte layout convention is: byte at addr+i = bits [(i+1)8-1 : i8] of the BV (LSB at lowest address). Mirror of BE where byte at addr+i = bits [(W-i)*8-1 : (W-i-1)*8]. Partial-load extract paths in load_concrete (exact-address branch ~line 542, symbolic_spans branch ~line 560) MUST branch on self.endness; before angr-v1q2 they were hardcoded BE, which silently returned MSB-side bytes for LE sub-word loads.
invariant-leaf-module-pyo3-prelude
remembered
Extracting a helper out of an exploration/.rs extension-impl module (super:: glob) into a leaf module like exploration/constraints.rs loses the glob's pyo3 trait imports: the body compiles inside the impl but fails with E0282 'type annotations needed' on Bound::call_method0/getattr once moved. Fix is a function-local 'use pyo3::prelude::*;' (plus explicit crate::symbolic::/crate::claripy_bridge:: paths) rather than widening the leaf module's top-level imports — keeps the leaf free of pyo3 surface it does not otherwise need. Seen in import_python_constraints (angr-9ke6b.71).
invariant-leak-gate-must-prove-it-can-fail
remembered
Leak-gate self-test caught a bug in the GATE, not the engine (angr-c4xcs.4). A deliberate leak injector written as std::mem::forget(Box::new([0u8;64])) is ELIDED ENTIRELY by release-mode LLVM: the allocation is never read and never dropped, so it is dead code. Result: valgrind saw nothing, the self-test reported clean, and the leak gate was silently incapable of failing. Fix: Box::into_raw(vec![0u8;64].into_boxed_slice()) + std::hint::black_box(ptr) -- opaque to the optimizer, unreachable to memcheck, so it lands in 'definitely lost'. Now measures exactly 64.00 B/iter. INVARIANT: any leak/perf gate must ship a self-test that proves it can fail, and that self-test must be run BEFORE the real check in CI (nightly-ci.yml::valgrind_leak_check does this). See inject_leak() in native/angr/examples/leak_probe.rs.
invariant-legacy-stat-numbers-unregistered
remembered
Legacy (pre-LFS) stat-family syscall numbers must stay UNREGISTERED in the Rust native registry on every 32-bit arch: i386/ARM 106/107/108, MIPS32 4106/4107/4108. The Rust writers (write_i386_stat / write_arm_stat / write_mips32_stat, dispatched by write_stat_for_arch in syscalls/file_path.rs) emit only the LFS 'struct stat64' layout used by the *64 numbers (195/196/197, 4213/4214/4215) — st_size at 0x30, 64-bit st_ino at 0x0C. Registering a legacy number to NativeStat/Lstat/FstatSyscall writes a correctly-sized but wrong-offset struct instead of falling back to Python, which DOES have procs for these numbers (angr's linux_kernel.py i386/arm/mips-o32 maps all define 106/107/108). angr-9ke6b.226 fixed exactly this for ARM 107. Older comments claiming 'angr's i386/arm maps have no legacy entry' were false — the correct rationale is the layout mismatch. mod_tests.rs::file_path_stubs_registered_on_all_arches now asserts all nine are absent.
invariant-libc-alias-not-duplicate-proc
remembered
Native SimProcedure duplication: when two libc names share semantics (glibc macro alias, or Python angr does x_unlocked = x), register ONE struct and list the other names in the aliases() trait method / declare_proc's aliases = [...] — do not copy the impl. Precedents: NativeFputc covers fputc/fputc_unlocked/putc/putc_unlocked (angr-9ke6b.113); NativeScanf covers scanf/__isoc99_scanf and NativeFscanf covers fscanf/__isoc99_fscanf (angr-9ke6b.112); strcoll->strcmp; printf->vprintf; fprintf->vfprintf. Registry.get(alias) returns the base proc, so proc.name() is the BASE name — tests that need to exercise the alias must go through NativeProcedureRegistry::get(alias).call(), not a struct literal (scanf_tests.rs test_isoc99_scanf / test_isoc99_fscanf_basic show the pattern; scanf_tests.rs needs an explicit use crate::procedures::NativeProcedureRegistry; since use super::* only reaches the scanf module).
invariant-libc-start-main-only-after-main
forgotten
During Rust exploration, the only __libc_start_main address that fires is the after_main synthetic continuation. rust_manager._step_python_to_main runs the entry/run + inside_init/after_init chain in Python and caches the result; by the time Rust takes over, the callstack only has after_main on top (its Python body is self.exit(0) → deadend). This is why NativeLibcStartMain at native/angr/src/procedures/libc_start_main.rs is safe as a no_return=true deadend: it never sees the entry/inside_init/after_init paths. If a future flow bypasses Python init, set_python_override('__libc_start_main') is the conservative escape hatch. Bead: angr-yrhh.
invariant-libc-test-hook-addr
remembered
When integration-testing a SimProcedure via RustExplorationManager, the hook address MUST be inside a real loaded binary segment (e.g., fauxware's 0x400000-0x400a74 .text/.fini range), NOT in cle## loader objects (TLS at 0x900000, kernel at 0xa00000) or in unmapped gaps. Otherwise rust_manager.py:_run_python_init_if_needed sees: obj is None or obj.binary starts with 'cle##' AND addr is in proj._sim_procedures, then runs Python init to main, ignoring the hook entirely. Use 0x4008c0 (in fauxware's .fini area) — find_object_containing returns the fauxware ELF, init short-circuits, the hook fires.
invariant-libvex-native-lifter-arch-support
remembered
The native libVEX lifter (NativeLibVEXLifter::lift in native/angr/src/vex/libvex_lifter.rs) accepts AMD64, ARM, ARM64, MIPS32 and MIPS64. The arch gate is fn ffi_vex_arch: it returns None -> LiftError::InvalidArch (caller falls back to the pyvex-callback path) only for X86, PPC32, PPC64 and S390X. AMD64 was Stage-1; the other four were added by angr-qwyti.20 (commit 195e25ca3), which also grew the corpus parity gate beyond AMD64. fn archinfo_for is arch-generic: every archinfo.Arch emits baseline hwcaps 0, empty cache info and x86_cr0=0xFFFFFFFF, so the sole per-arch difference is endianness (MIPS big, rest little) -- keep parity with gen_libvex_corpus.py. Pinned by libvex_lifter_tests.rs::test_supported_arches_are_not_rejected and ::test_unsupported_arches_rejected. Adding a new arch = add the ffi::VexArch mapping in ffi_vex_arch (archinfo_for needs no change) plus corpus blocks. Superseded the old 'AMD64-ONLY' claim, which was stale from 2026-07-25 to 2026-08-02 (angr-9ke6b.171).
invariant-libvex-restricted-vector-consts
remembered
libVEX encodes Ico_V128 / Ico_V256 as a RESTRICTED 'one bit per byte lane' pattern (16-bit for V128, 32-bit for V256): bit i set => byte i is 0xff, clear => 0x00. pyvex EXPANDS this in IRConst.V128._from_c / V256._from_c before the const reaches the engine, so any native marshaller (libvex_lifter.rs::marshal_const, feature libvex-ffi) MUST expand too — see expand_v128 / expand_v256. Passing the raw pattern through is a silent VALUE divergence, not a shape one (pcmpeqd xmm0,xmm0 => native V128(0xffff) vs pyvex V128(0xff..ff)); the structural-Debug parity gate DOES catch it once a V128-const block is in the corpus. pyvex ships as .pyc only — disassemble with dis.dis(pyvex.const.V128._from_c) to read its semantics.
invariant-lift-deadend-message-routing
remembered
Rust run loop deadend-vs-errored routing is by MESSAGE SUBSTRING, not error type. exploration/stepping.rs RunResult::Error arm: 'if message.contains("No bytes in memory") || message.contains("lift") || addr==0 => Deadended else Error'. The Python lift callback rust_manager.py _cb_lift_block returns the literal "{}" as a designed sentinel when a block can't be lifted (catches SimEngineError/ClaripyError/PyVEXError, warns 'Lift error at 0x..', returns "{}"). That "{}" fails deserialize_irsb on the Rust side; the resulting CbExecutionError::LiftError Display ('lift error: ...') matches the 'lift' substring -> graceful deadend no-op. CONSEQUENCE: do NOT reclassify the deserialize-failure site (interpreter/execution.rs, the irsb deserialize map_err) from LiftError to InvalidIR/MalformedIRSB without first making stepping.rs route on a typed signal -- MalformedIRSB's Display lacks 'lift' so unliftable blocks route to errored, breaking Callable ('No paths returned') and test_strict_page_access_alone_does_not_block_nx_fetch. This is why angr-ghwsd.5 fix2 was deferred.
invariant-lineage-flag-propagation
remembered
SymContext per-lineage flags (deterministic, use_shared_lineage_solver) are inherited by fork() so a whole diamond stays in one witness-selection mode. with_timeout() hardcodes BOTH to false. Any code that builds a SymContext via with_timeout/new and represents a continuation of an existing lineage MUST re-propagate these flags. Known sites that must: merge() (snapshot_fork_ops.rs, OR across all input arms) and to_snapshot/restore_from_snapshot (SymContextSnapshot carries them as serde-default fields, angr-ph300.46). Missing propagation silently reverts deterministic eval to arbitrary-Z3 witnesses -> run-to-run nondeterminism returns at the merge/restore point with no error. Accessors: is_deterministic/set_deterministic (solving_ops.rs), use_shared_lineage_solver/set_use_shared_lineage_solver (lineage_ops.rs, z3-gated).
invariant-linux-prot-vs-rust-permission
remembered
Linux mprotect PROT bits (PROT_READ=0x1, PROT_WRITE=0x2, PROT_EXEC=0x4) and Rust Permission::from_bits encoding (read=0x4, write=0x2, execute=0x1) are REVERSED on bits 0x1 and 0x4. Any native code ingesting raw Linux PROT values (mprotect syscall, future mmap) must translate explicitly — do NOT pipe through Permission::from_bits. See native/angr/src/syscalls/mprotect.rs::linux_prot_to_permission for the canonical conversion.
invariant-load-concrete-common-unified
remembered
angr-9ke6b.97: SymbolicMemory's two concrete-load entry points are now ONE body — load_concrete_common(addr, size, ctx, lazy) in native/angr/src/memory/load.rs. load_concrete = record_mem_load + common(lazy=false); load_concrete_lazy_inner = common(lazy=true). Do NOT re-fork them: the split is exactly why Multi-cell gating (angr-9ke6b.96), the angr-3zhl partial-overlap merge and the endianness-aware containing_wider_sym extract each shipped in only one copy. The lazy flag feeds ONE decision, unmapped_page_error(page_num, lazy, fallback): lazy + is_in_lazy_region -> UnmappedPageInRegion (interpreter fetches the page), else the caller's hard Unmapped, whose shape differs per call site (whole page on the single-page fast path, one byte on the cross-page walk). Second invariant: record_mem_load stays on load_concrete ONLY — the lazy path is reached from ite_builder leaves and store_concrete's read-modify-write, so bumping it there double-counts (see invariant-mem-counter-two-paths). Any new load feature belongs in load_concrete_common, never in a wrapper.
invariant-load-concrete-slow-path-endness
forgotten
load_concrete in native/angr/src/memory.rs has TWO endianness-sensitive symbolic-reconstruction paths in the has_symbolic fallback: (1) per-byte concat at lines ~661-680 — for LE the parts vector is concatenated parts[N-1]::...::parts[0] (byte 0 = LSB), for BE parts[0]::...::parts[N-1] (byte 0 = MSB); (2) wider-symbolic linear scan at lines ~692-712 — extract bits use the same LE hi=off_bits+size8-1/lo=off_bits vs BE hi=total-off_bits-1/lo=total-off_bits-size8 split as the fast-path symbolic_spans/exact-address branches at lines 553-556 / 576-582. ALL FOUR places must agree on endness. Fixed by angr-76mo on 2026-05-06; if you add a fifth path, branch on self.endness.
invariant-loader-pages-cache-weakkey
forgotten
RustExplorationManager._loader_pages_cache (added angr-bzsc, commit f7873d9de) is a WeakKeyDictionary keyed by the cle.Loader instance, holding {'batch_pages': [(page_addr, bytes, perms)], 'lazy_regions': [(start, len)]}. WeakKey was chosen over id(loader) because Python recycles object ids after gc and class-level cache would otherwise stale-hit if short-lived projects share an id with a recycled loader. cle.Loader supports weakrefs (verified). When Project (and its Loader) is GC'd, the cache entry auto-evicts; no manual capacity cap needed (one entry per live project; project lifetime drives memory). Test coverage: tests/engines/test_rust_exploration.py::TestLoaderPagesCache (hit, separate-projects, weakref-eviction). Cache populated by _sync_memory_to_rust slow path only — entry-state managers hit _try_fast_memory_sync first and bypass it, so cache is built lazily on first Callable-style construction.
invariant-loadg-cvt-carries-src-width
remembered
VEX LoadG (guarded load) cvt string encodes BOTH source and destination width (e.g. ILGop_16Uto32 = load 16 bits, zero-extend to 32). The destination temp type alone is ambiguous: an 8->32 and a 16->32 load both target a 32-bit temp. Rust's IRLoadGOp must carry src_bits (parsed in pyvex_bridge::parse_loadg_op) so interpreter/statements.rs derives load_size=src_bits/8. VEX only defines the *to32 widening ops (8Uto32/8Sto32/16Uto32/16Sto32); *to64 forms are handled defensively. Unknown cvt -> IRLoadGOp::Unknown which errors InvalidIR at exec (do NOT default to Identity — silently truncates).
invariant-loadg-fallback-coverage
remembered
Hand-built IRSB JSON via lift_block callback is the cleanest way to test specific VEX statement dispatch paths (LoadG, StoreG, CAS, Dirty) from Python without needing a real binary that emits them. Pattern: _RustExplorationManager + RustSimState + PythonCallbacks; lift_block returns a static JSON string with the target IRStmt. Caveat: forcing a specific ConcretizationResult shape (TooLarge / Failed) is hard from outside — concretize.rs uses fast-enum (16 solutions) + range-seeded binary search + stride detection. An unconstrained 64-bit BVS often resolves to Multiple via fast-enum, and tests that need TooLarge specifically should set configure_concretization_strategies(False, 0, 0, False) AND construct a value with > 16 distinct solutions whose differences have GCD = 1 (defeats stride detect). Tests in tests/engines/rust/test_error_stash.py for angr-2q5k assert the dispatch reached resolve_loadg_load by accepting either memory_load_symbolic_full firing (sym-shape branches) OR memory_load firing for a non-source address (Single/Multiple).
invariant-local-constraints-struct
remembered
native/angr/src/symbolic/context.rs has a private LocalConstraints struct that combines both assumed (Vec<(RustBV, bool)>) and z3_assertions (Vecz3::ast::Bool, gated on vex-engine-z3) under one Mutex. This replaced the prior two separate Mutex<Vec<...>> fields (assumed_constraints_local + z3_assertions_local). Why: hot path (assume_true/assume_false/add_constraint_raw) used to acquire 2 locks per call; now 1. How to apply: when adding new assumed/z3_assertion writes, lock once via self.local_constraints.lock() and access fields via .assumed and .z3_assertions. Reads should follow the same pattern; never reintroduce separate Mutexes for these two vectors. The shared Arc<Vec<...>> prefixes (assumed_constraints_shared / z3_assertions_shared) remain as separate fields — only the local additions are combined. Fork-time freeze: context.rs::freeze_into_shared<T: Clone> is the generic helper that replaced the prior two specialized fns (freeze_z3_assertions, freeze_assumed_constraints). It takes a &Mutex<Arc<Vec>> shared prefix plus a &mut Vec local — the caller already holds the LocalConstraints lock and passes a mutable ref to the field. This avoids a redundant lock/unlock cycle inside fork() and lets fork freeze both vectors under one local-lock acquisition. Never re-introduce per-vector freeze fns or have freeze acquire the local Mutex itself — fork() (z3 and non-z3 paths) is the single caller, and it locks LocalConstraints once and passes &mut local.assumed and &mut local.z3_assertions. Cloning-vs-moving discipline: in_transaction always clones (rollback needs local intact); otherwise Arc::get_mut+append moves elements without per-element clones.
invariant-loop-summary-bead-id-extraction
forgotten
summary.jsonl bead_id extraction in run_optimization_loop.py uses two-tier lookup: (1) parse new HEAD commit message for /\bangr-[a-z0-9]+(?:.\d+)?\b/ (handles plain 'angr-2q5k' AND parent-child 'angr-4j5u.1'); (2) fall back to '## Task: ' line in .claude/loop-session.md (works when session updates state but doesn't commit, e.g. dirty-rollback iters or budget-exhausted iters). Both paths are best-effort — bead_id may be None. Don't rely on --output-format=json to surface the agent's bd-update tool calls; it only emits the final result envelope, not intermediate Bash invocations.
invariant-loophead-key-cache
remembered
LoopHeadRoundRobin::select (exploration/selection_policy.rs) memoizes bucket_key via a state_id-keyed key_cache Mutex<HashMap<u64,u64>>. INVARIANT: a state's bucket key is fixed while it sits in the active deque (history/callstack change only after select removes+steps it), so cache once per sighting and EVICT the dispatched state's entry on removal (forked children re-enter with fresh state_ids -> no stale reuse). Bounds cache to ~active.len(); collapses full drain from O(active^2) key computations to O(active). key_cache MUST stay only-indexed (get/insert/remove, never iterated) to preserve the no-hash-order-in-selection determinism contract. #[cfg(test)] key_computations AtomicU64 counts cache misses for the O(active) assertion.
invariant-lr-abi-no-return-addr-at-sp
remembered
On link-register ABIs (ARM/ARM64/MIPS, CallingConvention::pops_return_addr()==false) the return address is NOT at [sp] — nothing is pushed at the call, so [sp] holds an unrelated caller local. Any interpreter fast path that peeks at the stack for a return address must gate on pops_return_addr() and defer to CallingConvention::get_return_addr (which reads link_register()) otherwise. VEXInterpreter::get_return_addr had this bug until angr-9ke6b.87: its pending_stores/all_flushed_stores probe ran unconditionally, so a buffered write at [sp] was returned as the ARM return address.
invariant-macro-rules-match-arm-position
remembered
Rust macro_rules CANNOT expand to multiple match arms when invoked in arm position (i.e. between two commas inside match). Rust parses macro_invocation, as a single arm-pattern, then expects => expr. Got 52 'match arm with no body' errors when trying macro!() , macro!() , inside match cc_op {}. WORKAROUND that works (used in vex/ccall.rs::cc_op_match!): wrap the WHOLE match (including 'match cc_op {' and '_ => return None,}') in the macro. The macro takes per-family data and emits the entire match expression. Limitation matters whenever you try to dedup match arms across categories.
invariant-makefile-lint-via-precommit
forgotten
Makefile Python lint/format targets must route through pre-commit (.pre-commit-config.yaml owns ruff/pyupgrade pins), NOT direct 'python -m ruff' or 'python -m pre_commit'. Reason: the .venv does not ship ruff or pre-commit. pre-commit is expected to be installed system-wide (pipx install pre-commit). CI uses pre-commit too, so this matches. Direct ruff/pre_commit module invocations fail with ModuleNotFoundError. Discovered while wiring angr-4ffe Makefile lint/fmt targets.
invariant-materialize-deferred-forks-single-site
remembered
materialize_deferred_forks (exploration/fork_materialize.rs) is the single source of truth for the SERIAL deferred-fork pipeline: condition lookup -> P11 condition_ast reconstruct -> P15 conservative fork -> build_unexplored_fork -> SAT split. Callers: step_one's find/avoid arm (run_loop.rs) + _resume_after_simprocedure / _deadend_pending_callback / _resume_after_symbolic_branch (resume.rs). Per-caller variation lives in MaterializeForkCtx: guard_sink (Some = continuing state receives the taken-path guard) and stats (Some iff profiling). set_root stays caller-side (needs &mut StateManager) and MUST be applied to BOTH the sat and unsat vectors — every legacy copy registered lineage before the SAT check. Two quirks preserved on purpose: run_loop + deadend DROP unsat forks (no pruned-stash push), and the batch timer is charged even for an EMPTY fork list. The parallel mirror materialize_deferred_forks_core (core_outcome_handlers.rs) is NOT folded in — no P11 fallback, fires the constraints inspect BP, atomics not ExecutionStats (angr-ph300.73).
invariant-materialize-pending-ite-load-store-same-range
remembered
angr-9ke6b.100 (commit b3efadc99): SymbolicMemory::materialize_pending_ite's 'Err(_) => zero' fallback for the ITE else-arm was NOT observable through the public API — store_concrete_lazy immediately re-checks the identical page range via check_pages_mapped_lazy, so a load that fails with UnmappedPageInRegion is always followed by a store that fails with the same error. Consequence for anyone auditing this family: you cannot write a before/after discriminating test for load-vs-store error classification inside materialize_pending_ite; tests there are contract pins only. A discriminating case would need PendingWrite.size to disagree with value.width()/8 (load spans a page the store does not), which is a malformed write. The fix is still worth having: it drops a wasted Z3 ITE build and stops the wrong 'current' from becoming reachable if a store path ever auto-maps.
invariant-materialize-state-finalize-tail
remembered
Rust engine state materialization (_materialize_single_state in angr/exploration/rust_state_export.py) has FOUR paths: (1) cached SimState in _state_cache, (2) parent-root copy when the root is cached, (3) currently-stepping-state copy, (4) full export_state() as last resort. INVARIANT: any per-state fixup you want applied to EVERY materialized state must go in _finalize_materialized_state — that is the only tail all four paths share. Two things this rules out, both tried and failed in angr-op0dn.13.14: (a) putting the fixup in _restore_plugins_to_state — it only runs on the full-export path, so it silently never fires for a Python-SimProcedure-bounce workload whose found states come back through a cached path; (b) 'fixing' a missing plugin by seeding _state_roots/_state_cache so restored states take path 2 instead of path 4 — that DOES restore plugins but hijacks materialization away from the export path and LOSES THE CONSTRAINTS ENTIRELY (state.solver.constraints == []). Note also that _restore_plugins_to_state's copy guard is 'only copy if not already present', and SimState auto-registers a default posix on attribute access, so it is a near-noop for posix on a freshly-built state.
invariant-max-active-states-two-sites
remembered
max_active_states enforcement has TWO sites, not one. Serial: RustExplorationManager::push_to_active_or_drop (exploration/helpers.rs) against sm.active_count(). Parallel: worker::absorb_continues (exploration/scheduler_worker.rs) against WorkTransport::pending (queued + in-flight), the in-wave/in-session analogue -- the parallel frontier lives in worker-local VecDeques + the injector and only reaches STASH_ACTIVE at a wave boundary, so push_to_active_or_drop never sees it (angr-9ke6b.48). Any NEW frontier-push site must go through one of the two. absorb_continues discounts the parent task still counted in pending (pending-1) so a saturated frontier still admits one replacement child, matching serial where the stepping state is already out of STASH_ACTIVE; without that discount a cap of 1 halts the wave after one step. The bound is SOFT by up to workers-1 (each peer's in-flight parent is only discounted by its own worker). Over-cap parallel forks are counted as Pruned TerminalSummary entries (SchedulerCounters::summarized_pruned) and DROPPED -- unlike serial, they are NOT recoverable in STASH_PRUNED, so a Python-level test asserting counts['pruned']>0 (see max-active-states-test-pattern) does NOT work under RUST_PARALLEL_WORKERS>1.
invariant-mem-counter-two-paths
remembered
When adding global memory-volume counters to the Rust engine, there are TWO independent execution paths and bumping policy MUST account for both: (1) Rust-memory fast path: try_rust_memory_{load,store} → SymbolicMemory::{load,store}concrete → counter bump (lives at the SymbolicMemory layer). (2) Callback fallback path: try_rust_memory* returns None → load_concrete_addr/load_symbolic_addr/fallback_to_python_store, all of which bypass SymbolicMemory. To get parity, bump the counter at the cb-interpreter level in the post-try-rust branch — NOT at the top of eval_load (would double-count path 1) and NOT inside SymbolicMemory ONLY (misses path 2). State.rs hot path (native simprocs calling memory_load/memory_store) is path 1. Counters validated: mem_load_count/mem_store_count/mem_load_bytes/mem_store_bytes.
invariant-mem-ite-depth-counter
remembered
Phase 1+ lazy-memory work (angr-czph Multi cells, angr-qh5u STORE) MUST call symbolic::record_mem_ite_depth(depth) on every Multi cell insertion / load-time collapse — otherwise the Phase 0 baseline comparison breaks silently. The function is in native/angr/src/symbolic/context.rs and is the dual of the eager record_mem_ite_depth() calls already placed in memory/store.rs (store_strided, store_conditional_multiple, store_symbolic Multiple branch). Counter is global atomic, reset by reset_solver_stats() / mgr.reset_solver_stats(). Tests that read the counter MUST use delta-based assertions (pre vs post) because cargo test runs tests in parallel and the atomic is process-global.
invariant-mem-perm-check-points
forgotten
invariant-mem-perm-check-points
invariant-mem-perm-default-off
remembered
Memory permission enforcement in Rust SymbolicMemory is opt-in via set_enforce_permissions(true), default OFF. This mirrors angr Python's STRICT_PAGE_ACCESS state option. Why off by default: existing tests/benchmarks frequently map RWX or rely on Python side enforcement; flipping default would silently break callers that map regions with restricted perms expecting them to be ignored. To enable per-state, call state.inner.set_enforce_permissions(true) or via the PyState method.
invariant-mem-read-paths-use-load-layered
remembered
LoadG (and any future VEX statement that reads memory) must call VEXInterpreter::load_layered / load_layered_at, NOT load_from_callback. load_from_callback is the LAST rung of the ladder: it only tries synthesize_unservable_load then the raw Python memory_load callback, skipping try_rust_memory_load, pending_stores, pending_symbolic_stores, all_flushed_stores and try_read_concrete_memory. In production use_rust_memory=true and stores are never synced to Python (statements_store.rs 'Rust owns memory'), so a direct load_from_callback reads stale/unrelated data for any address written earlier in the same step. angr-9ke6b.83 fixed this for LoadG by extracting eval_load's ladder into load_layered (expressions.rs) and calling it from resolve_loadg_load. Regression tests: statements_tests.rs loadg_sees_pending_store_from_same_block / loadg_sees_rust_memory_store_from_same_block / loadg_single_concretization_sees_rust_memory_store.
invariant-mem-symbolic-addr-counter-sites
remembered
angr-9ke6b.229 (commit 6bbef3e00): mem_load_symbolic_addr / mem_store_symbolic_addr now tick from the PRODUCTION concretizer entry points, not SymbolicMemory::{load,store} (which nothing calls -- see invariant-symbolic-memory-load-store-wrappers-unused). Bump sites: SymbolicMemory::load_symbolic + load_symbolic_unified (load side), store_symbolic + store_symbolic_unified + store_symbolic_unified_multi + store_with_concretization (store side), each just past its concrete fast path; store_with_concretization tests addr.as_u64().is_none() instead, because the interpreter (try_rust_memory_store) concretizes one step earlier and hands the result in. THREE semantics traps for anyone reading these counters: (1) they are a SIBLING of mem_{load,store}_count, NOT a subset -- the counted load_concrete/store_concrete are not on the symbolic path at all (it reaches memory via the _automap/lazy variants, which per invariant-mem-counter-two-paths deliberately skip the volume bump). (2) they count ENTRIES into concretization, so the UnmappedPageInRegion page-fetch retry in try_rust_memory{load,store} ticks twice for one guest op. (3) a bench reading 0 is usually genuine -- fauxware/ais3_crackme/defcamp_r100 have concretize_read_count=concretize_write_count=0 too. Validate on sym-write, where all four read 8.
invariant-mem-write-paths-check-perms
remembered
Every SymbolicMemory write path must call check_perms_range(.., Permission::W) itself — there is no chokepoint. store_concrete does it; install_multi_for_candidates (the Multiple/Strided arm of store_symbolic_unified, via install_multi_for_candidates_safe) did NOT until angr-9ke6b.94, so a symbolic-address store silently mutated read-only pages whenever the concretizer happened to return >1 candidate. Two gotchas when adding such a check to a path that auto-maps: (1) run it BEFORE the auto-map loop, since auto-mapped pages are created RW and would make the check vacuously pass; (2) check_perms_range skips pages not present in self.pages, so pre-checking is safe for candidates whose pages don't exist yet. Check every candidate up front so a rejected store installs nothing (no partial mutation). See also invariant-mem-perm-default-off.
invariant-memcache-only-on-entry
forgotten
INVARIANT: RustExplorationManager._mem_cache (the fast memory-sync data
populated from the disk init cache) is only set when state.addr is the
project entry point OR a loader-pseudo-object address. For function-entry
starts (e.g. Callable workflows that begin at the function being called),
_run_python_init_if_needed returns the state immediately at
rust_manager.py:2317-2347 without consulting the cache, so _mem_cache
stays None and _sync_memory_to_rust takes the slow path. Any fast-memory-
sync optimization for Callable-heavy workloads must populate the cache
along an alternate path. See angr-bzsc for the planned class-level loader-
pages cache.
invariant-memory-config-direct-fields
forgotten
MemoryConfiguration at native/angr/src/exploration/memory_config.rs uses pub(crate) direct fields (same pattern as ProfilingCollector / ConstraintSolver). Callsites read/write self.memory_config.zero_fill_unconstrained, self.memory_config.concretizer_config, self.memory_config.vex_opt_level, self.memory_config.vex_opt_level_overrides directly. Do NOT add helper methods as a separate cleanup — parent angr-4j5u was deferred multiple times for cosmetic gain. Field-by-field access is the load-bearing simplicity.
invariant-memory-md-no-hardcoded-counts
forgotten
Quick-reference blocks in MEMORY.md should point at live commands (e.g. 'see grep -c "def test_" tests/engines/test_rust_exploration.py') rather than embedding hardcoded numbers like '210/210 passing'. Hardcoded counts go stale within days as new tests land. Likewise, 'currently regressed' lists go stale fast — link to the dated project_benchmark_status.md file instead. Confirmed by angr-jgxs which had to refresh both kinds of stale claims (210/210 was actually 243; '9 regressed via P0 bisect chain' was already fully closed).
invariant-memory-module-layout
forgotten
memory.rs has been split: native/angr/src/memory/ is now a directory module with mod.rs (3237 lines, SymbolicMemory + helpers + PendingWrite + MemoryError) and page.rs (Permission, MemoryPage, PAGE_SIZE, PAGE_MASK, BITMAP_WORDS, BITMAP_BITS_PER_WORD). The mod.rs re-exports the page items via 'pub use page::{...}', so crate::memory::Permission and crate::memory::PAGE_SIZE still resolve. When extracting further slices (symbolic_objects, ite_builder, concretize_glue per angr-0lre), preserve this re-export pattern to avoid touching the 27 dependents.
invariant-memory-pointer-vs-snapshot
forgotten
On rust-symex, memory files that try to snapshot live engine state (test counts, benchmark numbers, port status) rot fast and become misleading. The accepted pattern is: keep design rationale and 'why' in memory; point to CLAUDE.md for counts/state/architecture matrix. project_rust_engine.md was reduced to a thin pointer for this reason (angr-govb, 2026-05-13). Sibling siblings that ARE legitimate memory: project_core_goal (philosophy), project_rust_proxy_architecture (decision rationale), feedback_rust_python_boundary (lessons). project_benchmark_status.md is borderline — refreshed by hand, treat as ≤1 week fresh.
invariant-memory-submodule-privacy
forgotten
Rust submodules under native/angr/src/memory/ CAN access SymbolicMemory's private fields directly — Rust's privacy rule lets descendants of a module read private items declared in the parent. So when extracting a slice of SymbolicMemory methods into a child module (page.rs, ite_builder.rs, symbolic_objects.rs, concretize_glue.rs), no visibility changes are needed on the struct fields. Only the methods themselves need pub(super) if they used to be fn (private) and the parent module still needs to call them — see prepare_strided_region in concretize_glue.rs (commit 6a791be8a).
invariant-memory-tests-split
forgotten
memory/tests.rs is now split into memory/tests/{basic,symbolic,multi,ite_dedup}.rs as of 2026-05-30 (angr-v0hm, commit c8d71bc15). The parent declaration in memory/mod.rs is unchanged: 'mod tests;' resolves to memory/tests/mod.rs. Each subfile uses 'use super::super::;' (two levels) to reach memory:: — needed for private-field access (symbolic_objects, symbolic_spans, pages, etc.). Future memory tests go into the appropriate subfile by topic; if none fits, add a new sibling and register it in tests/mod.rs. Supersedes the older invariant-memory-tests-extracted memory which described the single-file layout.
invariant-merge-config-field-classes
remembered
RustSimState::merge (state/fork.rs) config-field policy, settled by angr-9ke6b.121: every field a native proc or Python setter can mutate AFTER a fork needs an explicit merge rule, not a self-only carry. Three classes: (1) set-like -> union: hooks and sim_options via the union_arc_set helper (returns self's Arc untouched when all branches are Arc::ptr_eq, so the no-divergence case allocates nothing), environment via a HashMap union with earlier-branch-wins + warn on a conflicting VALUE (mirrors union_overlay). (2) sim-option boolean mirrors (no_ip_concretization, no_symbolic_jump_resolution, keep_ip_symbolic, force_eager_forks) -> logical OR, because they are a second view of the sim_options set that is unioned; self-only carry would revert a branch's set_option. (3) per-path cursors and init-time config with no meaningful union (getopt_optind/optchar, getopt_extern, ctype_loc, concretizer, inspection enabled_mask) -> keep self's value but call warn_config_divergence, the same loud-drop contract the fs longest-stdout merge uses. When adding a new RustSimState field, put it in one of these three classes; the audit found the whole config block was silently in an unstated fourth class. AddressConcretizer/CtypeLocPtrs/GetoptExternAddrs carry PartialEq solely for the class-3 checks.
invariant-merge-overlay-earlier-wins
remembered
RustSimState::merge (native/angr/src/state/fork.rs::merge) resolves overlay-map key conflicts by earlier-wins iteration order: self > earlier-other > later-other. All three overlay maps (symbolic_pages, hook_symbolic_memory, addr_to_ast) share the identical union loop that only inserts a key if !contains_key, so the FIRST branch to own a key wins. A 2-state test cannot distinguish this from later-wins; needs >=3 states where a conflicting key lives in two DIFFERENT others but not self. Covered by state_tests.rs::test_merge_three_state_overlay_union_and_tiebreak (angr-qwyti.2/n0irt.18): tag entries by provenance (size field for hook/addr_to_ast, int AST value for symbolic_pages) so a reversed others fold flips the merged tag and fails. stdin_symbols unions with dedup-by-name, self-order-first.
invariant-merge-reconciles-per-context-settings
remembered
SymContext::merge (symbolic/snapshot_fork_ops.rs) builds its result from Self::with_timeout, which hardcodes every per-context setting to a DEFAULT — so any setting that is not an explicit part of the constraint merge must be reconciled ACROSS ALL ARMS at the end of merge(), never inherited from the receiver. Three are handled today: deterministic and use_shared_lineage_solver are OR'd (angr-ph300.46 — any arm that opted in wins, else RustExplorationManager(deterministic=True) silently loses determinism at the merge point) and timeout_ms takes the MIN (angr-9ke6b.140 — a timeout is an upper bound on per-query solver work, so the merged lineage respects the strictest arm's budget). Inheriting from self is a bug of shape 'order-dependent merge': a.merge([b]) and b.merge([a]) must agree. When adding a new SymContext field, decide its merge reconciliation rule in the SAME change and cover it in context_tests/merge_prefix.rs (test_merge_flags_or_across_arms, test_merge_reconciles_timeout_across_arms).
invariant-merge-stdout-only-fs
remembered
RustSimState::merge (state/fork.rs) uses a documented STDOUT-ONLY FileSystem merge contract (angr-ph300.75): the merged state adopts exactly ONE branch's FileSystem — the branch with the longest stdout buffer, ties keep the earlier branch (self > earlier-other > later-other). Every other branch's fd table, file offsets, writes, and content_sym registry are discarded. merge() now log::warn's for each dropped branch where FileSystem::has_fds_above_stderr() is true (the only case with real non-stdout state to lose). A true per-file ITE merge is out of scope: fd numbers collide across branches so a naive union is ill-defined. Do NOT expect post-merge reads of a file written only on a losing branch to see that write.
invariant-merge-union-overlays-max-brk
remembered
RustSimState::merge (native/angr/src/state/fork.rs) must UNION the three Py-AST overlay maps (symbolic_pages/hook_symbolic_memory/addr_to_ast) across self+others, not take self's via clone_py_metadata alone, and take max(heap_brk)/max(posix_brk)/max(mmap_base) — else branch-B symbolic overlay bytes are lost and merged malloc aliases B's live allocations. Conflict policy: keep-earlier (self > earlier-other > later-other) + log::warn. FileSystem/stdout merge (best_fs longest-stdout heuristic) is intentionally still self-only, tracked as a separate ph300 bead. Fixed angr-ph300.51.
invariant-merge-unions-heap-metadata
remembered
RustSimState::merge (state/fork.rs) must union EVERY branch's heap_metadata, not keep self's, because heap_brk is maxed on merge (angr-ph300.51) so a branch-only malloc stays reachable in the unioned memory. If its alloc_size entry lived only on the dropped branch, NativeRealloc (procedures/malloc.rs) defaults copy_len to the FULL new size (old_size.map_or(size, min)) -> over-read past the true old allocation. Fixed by HeapMetadata::union_from (state/types.rs): self-wins on address collision, freed unioned as a set. Same bug class as angr-ph300.51 (watermark max) and angr-n0irt.2 (cgc base/sinkholes): any per-branch heap bookkeeping must combine on merge, never be taken from self alone.
invariant-mgr-stash-property-vs-proxy
forgotten
RustExplorationManager exposes stash access in TWO forms: (1) Properties: mgr.found / mgr.active / mgr.avoid / mgr.deadended / mgr.unconstrained — materialize FULL angr SimStates for API compat. mgr.found also includes Python-side _predicate_found SimStates. (2) Methods: mgr.found_proxies() / mgr.active_proxies() / mgr.avoid_proxies() / mgr.deadended_proxies() / mgr.unconstrained_proxies() — return list[RustStateProxy] without SimState materialization. SKIP _predicate_found because those are already full SimStates that don't fit the proxy model. Both forms route through different code paths: properties go through _get_stash_states (RustStateExportMixin); proxy-methods go through _stash_proxies helper that just wraps state_ids in RustStateProxy. Use the proxy form when iterating many states to read addr/regs/eval; use the property form when the SimState plugin chain is needed (posix.dumps, claripy AST round-trips). Added angr-kwpi.3 (commit 68f590c9b).
invariant-min-max-width
remembered
SymContext::min(bv, false) and ::max(bv, false) return Option(u128), but the value is the BVs bit pattern reinterpreted as unsigned and then zero-widened to u128 (NOT sign-extended). For a 64-bit BV holding -42 = 0xffffffffffffffd6, min/max return 18446744073709551574 = (-42i64) as u64 as u128. Tests asserting on signed negatives must use (-42i64) as u as u128, not (-42i64) as u128 (which sign-extends to the high bits and is a totally different value). Hit this in test_atoi_negative_symbolic_digits.
invariant-min-max-witness-seeding
remembered
INVARIANT for min/max model-cache seeding (native/angr/src/symbolic/context.rs cached_model_eval + min + max): a cached model M is sound iff it satisfies the CURRENT constraint set — enforced by invalidate_model_if_inconsistent on add_constraint. So M(bv) = v is a feasible value of bv → min <= v and max >= v under both unsigned and signed interpretation. Two seeding rules: (1) tighten initial hi (min) / lo (max) bounds to v. (2) for signed, skip the MinInit (bv<0) / MaxInit (bv>=0) pre-check when v's signed interpretation already proves the predicate. Push/pop scopes do NOT trigger invalidate_model_if_inconsistent, so a model in cache can survive across temporary scope frames as long as the caller doesn't add inconsistent permanent constraints. Future model-cache lifecycle changes must preserve this.
invariant-mips-n64-cc-2026-05-10
forgotten
MIPS64 N64 calling convention added 2026-05-10 (angr-gzk8, commit 8bd93978c). N64 uses 8 integer arg regs $a0-$a7 (R4-R11, VEX MIPS64 offsets 48,56,64,72,80,88,96,104), return $v0 (R2 offset 32), return-addr $ra (R31 offset 264, register-based — no SP pop), stack_arg_offset=0 (does NOT reserve a save area for register args, unlike O32's 16-byte window). Aliases: mips64, mips64el, mips64le, mips64be. Defined in native/angr/src/arch/calling_conventions.rs::MipsN64. The historical invariant-mips-no-calling-convention memory is now fully obsolete.
invariant-mips-no-calling-convention
forgotten
OBSOLETE as of 2026-05-09 (commit 5f9eb0cf5, angr-orc9). MipsO32 calling convention is now defined in native/angr/src/arch/calling_conventions.rs and registered in default_cc_for_arch for mips/mips32/mipsel/mipsbe. Args $a0-$a3 (R4-R7, offsets 24/28/32/36); return $v0 (R2, offset 16); return addr in $ra (R31, offset 132); pops_return_addr=false. MIPS64 still falls through to SystemVAMD64 (no N64 calling convention defined yet) — that's the remaining latent risk if a MIPS64 SimProcedure-call test is ever added.
invariant-mips-o32-syscall-stack-args
remembered
MIPS O32 is the only Linux syscall ABI that spills syscall args to the stack: args 1-4 in $a0-$a3, args 5+ at [sp+16] (the C-ABI 16-byte save area). CallingConvention::syscall_stack_arg_offset() (calling_conventions.rs) returns None by default and Some(16) only for MipsO32; extract_syscall_args (exploration/helpers.rs, a RustExplorationManager method) uses it to read stack args 5+ when present, but ONLY for concrete SP — symbolic SP yields ExtractionError::SpSymbolic and unmapped slots yield StackUnmapped, both of which fall through to the Python syscall callback rather than fabricating zeros. amd64/x86/ARM/AArch64 expose all syscall args in registers, so a >reg-count request stays RegisterOverflow->Python. This unblocked native dispatch of MIPS32 6-arg futex(4238)/epoll_pwait(4313)/mmap2(4210). N64 has 8 reg args so it never needs the stack path.
invariant-mips-syscall-error-reg
remembered
MIPS Linux syscalls report error in $a3 (0=ok, non-zero=err) separately from $v0 return; glibc branches on it. Rust dispatcher must mirror angr's linux_syscall_update_error_reg: when unsigned ret >= SYSCALL_ERRNO_START (-1133 for both O32/N64), set error_reg=all-ones and ret=-ret (positive errno); else error_reg=0, ret passthrough. $a3 VEX offsets: 36 (MIPS32, base 8 + 74), 72 (MIPS64, base 16 + 78). Implemented via CallingConvention::syscall_error_register()->Option<(offset, errno_start)> in calling_conventions.rs + write_syscall_return() in exploration/stepping.rs (angr-pfbu).
invariant-mips32-inline-elf-opcodes
forgotten
MIPS32 instruction encodings verified for inline-ELF tests (endian-agnostic at decode; storage byte order set by EI_DATA): ADDIU rt,rs,imm=0x24080000|imm16 (rt=t0=8, rs=zero=0); BEQ rs,rt,off=0x10000000|(rs<<21)|(rt<<16)|off16 (off in PC-relative words, target = PC+4+(off<<2)); B off=0x10000000|off16 (BEQ zero,zero); NOP=0x00000000. Branch delay slot must always be filled (NOP works). For LE storage: struct.pack('<I', opcode). EM_MIPS=0x08 in ELF e_machine; ELF32 header=52 bytes, ELF32 phdr=32 bytes (field order differs from ELF64 — p_type,p_offset,p_vaddr,p_paddr,p_filesz,p_memsz,p_flags,p_align). e_flags=0x50001000 = EF_MIPS_ARCH_32 | EF_MIPS_ABI_O32.
invariant-mips64-inline-elf
forgotten
MIPS64 inline-ELF for integration tests: ELF64 header (64B) + PT_LOAD phdr (56B). EM_MIPS=0x08, EI_CLASS=2 (ELF64), EI_DATA=1 or 2, e_flags=0x60000000 (EF_MIPS_ARCH_64). N64 ABI is implied by EI_CLASS=ELF64 — no separate ABI flag needed in e_flags (unlike MIPS32 O32 which uses EF_MIPS_ABI_O32=0x1000). MIPS64 instruction encodings for base opcodes (ADDIU, BEQ, B, NOP) MATCH MIPS32 since registers are still 5 bits; ADDIU sign-extends the 16-bit immediate to 64 bits on MIPS64. Verified by test_mips64_explore_le_real_elf (commit 18850a432, angr-gxhf.3) using same 7-instruction shape as test_mips32_explore_le_real_elf. cle correctly parses such an ELF as arch=MIPS64/bits=64/Iend_LE.
invariant-mips64-n64-syscalls
forgotten
MIPS64 N64 syscall dispatch registered in native/angr/src/syscalls/mod.rs (angr-smtv, commit 3f85c7214). 65 entries mirror MIPS32 with N64 numbering from asm/unistd_n64.h / angr's mips-n64 table (linux_kernel.py). Key differences vs MIPS32 O32: (1) modern mmap (5009) — no old_mmap/mmap2 needed since N64 has 8 register args (a0-a7); (2) newfstatat (5252) exists in N64 unlike O32 which uses fstatat64; (3) no fcntl64 — N64's fcntl (5070) is already 64-bit; (4) no legacy time syscall — use gettimeofday (5094). Linux arch name 'MIPS64' matches MIPS64::name() at arch/mips.rs:616.
invariant-mirror-pattern
remembered
Mirror pattern for cross-mixin invariants in Rust rustdoc (angr-a2br.1.5, 2026-06-01): when the source-of-truth contract lives in a Python module docstring (e.g. angr/exploration/rust_manager.py:10-100 documents I1-I8), the Rust mirror at state.rs/stash.rs/run_loop.rs should: (1) add a module-level rustdoc section listing all invariants with their Rust manifestation (or 'Python-only' tag for orchestration-only invariants — don't pretend Rust enforces them when it doesn't); (2) cross-reference from each Rust enforcement site back to the module-level summary AND back to the Python source; (3) cite the matching regression test name verbatim (TestMmapBaseSync.test_export_path_..., TestStateCacheSizeBound.test_cleanup_state_cache_...) so a future reader can locate it. Debug_asserts are 'where runtime-expressible' — many cross-language invariants aren't (e.g. I7 max() direction is enforced at the Python export site, not Rust). Use them sparingly for genuinely Rust-internal contracts (state_index/state_roots consistency in stash.rs, register size>0 in state.rs::set_registers_bulk). Acceptance: cargo doc --no-deps clean, all tests pass.
invariant-mirror-solver-constraints-not-authoritative
remembered
Exported SimState mirrors do NOT carry the Rust constraint set in claripy. The authoritative read is _rust_mgr.export_state_constraints(state_id) (or a RustStateProxy); mirror.solver.constraints is whatever claripy list the mirror's base frame happened to have — often 0. A mirror built via rust_state_export.py::_materialize_single_state path 4 (full export_state -> _snapshot_to_angr) starts from project.factory.blank_state() and gets NO constraint import; _attach_rust_solver_fallback routes eval/min/max/satisfiable into the Rust solver instead (re-importing into claripy is expensive + identity-lossy; pre-pinning is UNSAT-prone, see constraint-export-no-pre-pin). Corollary: after load_snapshot the restored ids are absent from _state_cache/_state_roots, so EVERY restored state takes path 4 and every mirror shows 0 claripy constraints. That is by design, not a snapshot bug (angr-wuyo9).
invariant-mmap-base-mirror
remembered
RustSimState.mmap_base field (default 0xC100_0000 = heap_base 0xC0000000 + heap_size 0x00800000 * 2) mirrors Python state.heap.mmap_base for the mmap(2) syscall when addr=0 (kernel chooses). Bumped by NativeMmapSyscall on successful addr=0 mappings; stays unchanged on explicit-addr requests (matches Python mmap.allocate_memory). Mirrored in 3 constructors + 5 fork/merge sites across the native/angr/src/state/ module (mod.rs, fork.rs, snapshot.rs) alongside posix_brk. Accessors: state.mmap_base() / state.set_mmap_base(addr). NOT pushed back to Python's state.heap.mmap_base on syscall fallback today — same drift risk as posix_brk. See native/angr/src/syscalls/mmap.rs.
invariant-mmap-syscall-semantics
remembered
Linux mmap(2) syscall flag-decision gotcha: Python's procedures/posix/mmap.py compares 'fd[31:0] == -1' (low 32 bits == 0xFFFFFFFF), NOT the full 64-bit fd. Native handler must mask fd & 0xFFFFFFFF before checking for the anonymous sentinel — passing the full 64-bit value compared against u64::MAX silently breaks for callers that zero-extend a 32-bit -1 (gets passed as 0x00000000FFFFFFFF). Bad-flags fast path: when (flags & MAP_SHARED == 0) == (flags & MAP_PRIVATE == 0), Python returns BVV(-1, bits) directly (covers both the zero-bits case and the both-bits-set case). Native handler can mirror this without falling back. PROT bits are taken as prot[2:0] (mask & 0x7) before mapping — high bits are silently ignored. Source: native/angr/src/syscalls/mmap.rs (commit 7391b2426).
invariant-module-split-test-file-boundaries
remembered
Splitting a big Rust module in this crate: the sibling _tests.rs file must be split along the SAME boundaries, and its section-comment markers are NOT trustworthy boundaries. In helpers_tests.rs (angr-9ke6b.76) the '// --- Timeout / LengthLimiter / LoopBound ---' banner covered ~460 lines that also contained the effective_pc tests and the whole materialize_deferred_forks block; slicing on the banners produced dangling doc comments ('error: expected item after doc comment') and cross-file fixture references. Reliable method: (1) list every top-level 'fn ' with line numbers and classify each by the symbol it exercises, not by the banner above it; (2) walk BACKWARD from each fn over contiguous ///, //, and #[..] lines to find the true block start; (3) after cutting, grep each new file's head/tail for a trailing doc comment or a leading orphaned fixture. Shared test fixtures (e.g. push_active_at) do not survive the split -- either move them with their only consumer or inline the one-off call; a #[cfg(test)] mod tests is private, so cross-module fixture sharing is not available without making the tests mod pub(crate).
invariant-move-state-same-stash-reorder
remembered
Do NOT add a from_stash==to_stash no-op guard to _move_state (exploration/state_lifecycle.rs). Unlike _move_states (which guards to dodge a bulk double-insert/drop bug, angr-ph300.17), _move_state's same-stash remove-then-push_back reorder-to-back is LOAD-BEARING: _StashDict.setitem (rust_state_proxy.py) rebuilds a stash in caller-specified order by calling move_state(sid, cur, key) with cur==key per state (angr-wxuo). Adding the guard breaks test_stash_assignment_reorders. The two methods intentionally differ on same-stash semantics. Pinned by Rust test move_state_same_stash_reorders_to_back (angr-04tw3.4).
invariant-move-states-same-stash-noop
remembered
_move_states (exploration/state_lifecycle.rs) no-filter branch pattern remove(from)+ensure_stash(to)+insert(from,empty) is destructive when from==to: ensure_stash recreates the same key, append refills it, then insert overwrites with an empty VecDeque, dropping all states and dangling index entries. Fixed by guarding from_stash==to_stash -> return Ok(0) at fn top so all paths (no-filter, filter, single _move_state) agree. Rust regression tests in state_lifecycle_tests.rs. Note StashManager.insert is a raw HashMap::insert (stash.rs) — it clobbers, does not merge.
invariant-multi-cell-routing
forgotten
Phase 1.4 (angr-5zw8, commit e6e8268f3) wires MultiwriteAnnotation detection in rust_manager.py:_cb_memory_store_symbolic_full. Flow: addr_ast.has_annotation_type(MultiwriteAnnotation) AND _get_stepping_state_id() not None → _rust_state_memory_store_symbolic_multi(sid, addr_ast, data_ast) which calls _rust_mgr.state_memory_store_symbolic_multi (PyO3). On True the Rust Multi-cell write took the store; on False we fall through to the existing state.memory.store(addr_ast, data_ast) so writes are never lost. The _rust_state_memory_store_symbolic_multi wrapper exists ONLY because PyO3 methods are read-only at the binding level — tests need to monkey-patch routing. Annotation preservation across the bridge relies on the EXPRESSION_BY_OPERANDS_PTR reverse cache (claripy_bridge.rs:80-83, 1030-1034); this works for SimProcedure-originated ASTs imported once, but DOES NOT propagate annotations through Rust-side BV arithmetic. Phase 2 (angr-qh5u) will route Multiple/Strided through Multi-cell unconditionally, making the annotation check moot for the common case.
invariant-multi-cell-routing-safe
remembered
Phase 2 lazy STORE introduced install_multi_for_candidates_safe (memory/store.rs) — the production-safe entry that respects lazy regions. Three behaviors codified: (1) candidate page in declared lazy_region but unmapped → return UnmappedPageInRegion so interpreter fetches from Python; (2) candidate page unmapped AND non-lazy → silently filter (matches prepare_addresses_for_ite skip behavior; addr was concretized to it so it's nominally unreachable); (3) candidate page mapped → install Multi byte alternatives. install_multi_for_candidates (without _safe suffix) keeps its auto-map-as-RW behavior for test rigs and SimProcedure-direct calls via state_memory_store_symbolic_multi (Phase 1.4 PyO3 path). DO NOT route the production interpreter Store callback through the bare install_multi_for_candidates — it would auto-map zero pages over Python backer data.
invariant-multi-load-collapse
forgotten
Phase 1.2 (load_concrete_lazy_inner, commit 5030e0989) collapses each Multi byte via a RIGHT-FOLD of MultiPayload.alternatives() — alt[0].cond becomes the outermost ITE, and the final ELSE is the page's concrete byte. The concrete-byte default is required because ITE construction needs a leaf even though by invariant exactly one alternative cond is true under any model. Per-byte parts are then concatenated endianness-correctly mirroring try_byte_merge_load (LE: byte 0 = LSB; BE: byte 0 = MSB). Detection runs BEFORE the symbolic_objects fast path because Multi supersedes Symbolic. The helper is assemble_load_with_multi in memory/load.rs. record_mem_ite_depth(payload.len()) is called once per Multi byte collapse to honor invariant-mem-ite-depth-counter.
invariant-multi-load-not-in-load_concrete
forgotten
Phase 1.2 only patched load_concrete_lazy_inner — NOT the sibling load_concrete (memory/load.rs:37). Multi cells installed before Phase 1.3 store helpers exist only via the test-rig set_multi_alternatives entry point, so production loads going through load_concrete won't see Multi yet. Once Phase 1.3 (angr-aija) emits Multi cells from store paths, downstream loads via the VEX interpreter route through load() -> load_concrete (NOT _lazy), so a parallel detection block will need to be added to load_concrete. Mirror the same early-out (any byte in range in multi_objects -> assemble_load_with_multi) above the symbolic_objects fast path.
invariant-multi-store-merge
forgotten
Phase 1.3 (commit 405f4c3db) install_multi_for_candidates MERGES new (cond, byte) alternatives with any existing MultiPayload at each byte address instead of replacing — it reads the existing payload via multi_objects.get, pushes the new alt at the end, and re-installs via set_multi_alternatives. The merge is safe because per the MultiPayload invariant, conds across alternatives are disjoint address-equality checks (addr == c_i for distinct c_i), so at most one cond holds under any model and order does not affect the load result. Each set_multi_alternatives call records the full new payload length, which means a subsequent store with overlapping addresses will overcount the depth counter — that is expected because the counter measures total alt-insertion work, not unique alts.
invariant-multi-versions
remembered
Phase 4.1 introduced multi_versions: FxHashMap<u64, u64> on SymbolicMemory — a per-byte monotonic counter that MUST be bumped by any code path that mutates a Multi cell at byte_addr. Currently bumped on: set_multi_alternatives, clear_multi_at (only when payload existed), and per-byte inside flush_multi_cells. Versions persist across removal/reinstall so post-flush fingerprints differ from cached pre-flush ones even with identical alt counts. Any future write path that mutates multi_objects without going through these three entry points MUST also call self.bump_multi_version(addr) — otherwise the wider_load_cache (memory/mod.rs) will serve stale BVs. The cache key is (addr, size); fingerprint is Vec<ByteFingerprint::Multi{version, default_byte} | Concrete{byte}>.
invariant-multi-vs-symbolic-cell-states
remembered
After Phase 1.1, an address byte in Rust SymbolicMemory can be in one of four states: (1) pure Concrete (no bitmap bits, no sidecar entries); (2) plain Symbolic (page.is_symbolic bit set, entry in symbolic_objects and/or symbolic_spans, NOT in multi_objects); (3) Multi (page.is_multi bit set, entry in multi_objects, NOT in symbolic_objects — set_multi_alternatives clears the symbolic entries); (4) inconsistent/transitional (any other combination, treated as a bug). The 'Multi supersedes Symbolic' rule is enforced by the set_multi_alternatives setter — see test_multi_supersedes_existing_symbolic in native/angr/src/memory/tests/multi.rs. Future load.rs integration (angr-n082) and store helpers (angr-aija) MUST preserve this invariant: when transitioning a byte's state, clear the old sidecar entries before installing the new one.
invariant-multi-xor-symbolic-cleanup
remembered
Multi-xor-Symbolic invariant enforcement (angr-9ke6b.95, .96, .101 family): every code path that transitions a byte between the Multi and Symbolic states must clean the OTHER sidecar, because multi_bitmap and symbolic_bitmap are disjoint and load dispatch checks multi_objects FIRST. Use SymbolicMemory::clear_multi_at (memory/multi.rs) rather than hand-rolling the multi_objects.remove + page.clear_multi + bump_multi_version triple. In store_concrete the cleanup must run BEFORE the symbolic branch's page-cloning loop, which clones each MemoryPage, mutates the clone, and re-inserts it -- a clear_multi_at issued mid-loop would be flushed away by the stale clone. set_multi_alternatives already does the mirror direction (removes symbolic_objects/symbolic_spans on install).
invariant-naming-conventions
forgotten
Rust crate native/angr/src parameter naming (codified in native/angr/NAMING_CONVENTIONS.md, 2026-05-07): addr (not address), ctx (not context), py (not python), state for RustSimState, data: &[u8] for stored content, bv: &RustBV for generic borrowed BV, value: &RustBV for stored values in memory APIs. Intentional exception: segmentlist.rs uses 'address: u64' because it mirrors angr Python public API in angr/rustylib/init.pyi. Use the canonical names in new code. Why: codebase audit showed crate is already 240:8 addr-vs-address etc.; the conventions doc ratifies the de facto style. How to apply: when adding a parameter, check the table in NAMING_CONVENTIONS.md; for PyO3-exposed methods that mirror upstream Python, follow the upstream parameter name even if it conflicts.
invariant-native-dispatch-find-avoid-gate
remembered
invariant: an address-based find/avoid target that is ALSO a hooked SimProcedure address must never run natively. Native dispatch (exploration/core_outcome_handlers.rs::handle_simprocedure_core) is INLINE — it runs the proc body and sets pc=return_addr — so the target address never surfaces at a step boundary and the run-loop find/avoid check never fires. Fixed angr-1i5h7 (iter85): handle_simprocedure_core now computes is_find_or_avoid from ctx.find_addrs/ctx.avoid_addrs and forces NativeProcDisposition::Fallback, so the Python bounce path's existing short-circuit (run_loop.rs::step_one NeedCallback CallbackReason::SimProcedure special case, and the parallel MatKind::Bounce 'Bug C1' route via bounce_target_addr) routes the state to FOUND/AVOIDED before any proc body runs. run_loop.rs::step_one's OWN inline native path needs no guard: its pre-step find/avoid check on pc runs before the hook block. Symptom without the fix: explore(find=) runs straight past the target whenever a native proc exists for that name (this was the root cause of the xmllint_getenv 'catastrophic regression' recorded on angr-a8epx). Any NEW inline native dispatch site must replicate this gate — see avoid-fixing-only-one-native-dispatch-path.
invariant-native-dispatch-test-pattern
remembered
Native procedure dispatch fires only when hook addr is OUT of binary_regions (skipped extern objects + non-executable sections of real binaries) AND the addr is in a real binary so _run_python_init_if_needed short-circuits. Sweet spot for testing native dispatch with fauxware: addrs in non-executable sections like .ctors (0x600e30+). Use proj.factory.blank_state(addr=NON_EXEC_INBIN_ADDR) and set state.memory.store(rsp, RET_ADDR) for return address. After native proc returns, dispatch reads return_addr from rust_memory; for chained calls populate via mgr._rust_mgr.active_states_map_memory(rsp, ret_addr.to_bytes(8,little), 7). No public API to update registers between mgr.run calls, so RDI/RSI for the next native call must be the same as the previous.
invariant-native-lift-bypasses-lift-callback
remembered
INVARIANT: the native libVEX seam (try_native_lift in interpreter/execution.rs) bypasses the _cb_lift_block Python callback ENTIRELY — it reads block bytes from rust_memory via read_concrete_bytes_for_lift. Two consequences. (1) SMC still works, but by a different mechanism: rust_memory holds the STORED bytes, so no dirty_bytes plumbing is needed; the callback-path dirty-bytes contract test (test_smc_rust_passes_dirty_bytes_to_python_lift, tests/engines/rust/test_error_stash.py) must therefore construct its manager with use_native_lift=False or it records zero lifts on a feature-on build. Companion test_smc_native_lift_executes_stored_bytes proves the native path behaviorally. (2) Any test/tool that observes lifting by wrapping _cb_lift_block will silently see nothing on an AMD64 feature-on build now that use_native_lift defaults True. NOTE: interpreter lift counters (rust_native_lift_count / rust_native_lift_fallback_count) are only published in mgr.stats after mgr.enable_profiling().
invariant-native-lift-byte-source-order
remembered
libVEX native-lift byte source (fn native_lift_source_bytes, interpreter/execution.rs): sidecar (rust_memory.read_concrete_bytes_for_lift) FIRST, because it reflects stores — that is what makes SMC work on the native seam with no dirty-bytes plumbing. But a code page is only in the sidecar once something faults it in, so COLD blocks (first block of a page; every block of a load_shellcode blob) read empty there and used to fall back to pyvex. Fallback order is now: sidecar -> load-time binary-region store (VEXInterpreter::read_concrete_prefix over concrete_memory, populated from _load_binary_regions). INVARIANT: never serve the binary-region bytes for a page is_code_range_dirtied() reports dirty — the load-time image is stale there and would silently lift the pre-store program; those must keep routing to the callback's byte_string= path. Pinned by test_native_lift_serves_cold_page_from_binary_regions + the two SMC tests in tests/engines/rust/test_error_stash.py.
invariant-native-proc-fallback-buckets
forgotten
Three per-reason fallback buckets in NativeProcStats (profiling.rs) MUST sum to python_fallbacks: symbolic_fallbacks_by_name (SymbolicArgument), not_implemented_fallbacks_by_name (NotImplemented), other_fallbacks_by_name (MemoryError + MaxIterations + Other). New ProcedureError variants must update both stepping.rs and run_loop.rs Err(_) match arms — both dispatch paths need to bucket the same way. Test invariant in test_python_procedure_symbolic_arg_falls_back_to_python.
invariant-native-realloc-frees-stricter-than-python
remembered
Native realloc's heap bookkeeping is DELIBERATELY stricter than the Python reference -- do not 'fix' it toward parity. angr's SimHeapBrk._realloc (angr/state_plugins/heap/heap_brk.py) never calls free at all: it allocates, copies, returns. NativeRealloc (native/angr/src/procedures/malloc.rs) does call state.heap_free(ptr) for every non-NULL ptr, including realloc(ptr, 0) (POSIX/glibc 'free and treat as malloc(0)'), so HeapMetadata.free_count / is_allocated stay truthful for downstream double-free and leak analysis. Both are metadata-only -- the bump allocator never reclaims -- so the divergence is invisible to the memory model and cannot change a path's satisfiability. The bug fixed in angr-9ke6b.117 (commit e4305cdd3) was that heap_free shared the copy loop's 'ptr != 0 && size > 0' guard; the two conditions are now split (free on ptr != 0, copy on size > 0). Covered by test_realloc_zero_size_frees_original in procedures/malloc_tests.rs.
invariant-native-returnunconstrained-stub
remembered
Native ReturnUnconstrained stubs (procedures/stub.rs::NativeReturnUnconstrained) cannot live in the static NativeProcedureRegistry: a SimLibrary stub is keyed on the BINARY's symbol name (get_flag, some_unresolved_import), not a libc name, and its return width comes from that hook's prototype. They are built at setup time from Python: rust_callback_dispatch::_unconstrained_stub_spec filters project._sim_procedures (exact class ReturnUnconstrained, no return_val= kwarg, prototype returnty size known) and RustExplorationManager._register_simprocedures passes (name, ret_bits) to the PyO3 method register_unconstrained_stubs, which skips any name that already has a real native proc. Two traps: (1) at registration time the prototype's SimType is NOT arch-bound, so returnty.size raises ValueError('Cant tell my size without an arch!') — call with_arch(project.arch) first; (2) the symbol width must be the prototype width, not arch bits, because Python stores it via SimCC.return_val(ty) which refines the return register (a narrow return is a partial register write, eax within rax).
invariant-native-simproc-empirical-gate
forgotten
Native-SimProc decision rule (2026-06-02, angr-4c65 closed wontfix): Before implementing a native SimProc that requires plumbing new RustSimState fields (e.g. state.globals slots, FD tables, save pointers), first run tests/benchmarks/collect_simproc_fallbacks.py to confirm the proc actually fires in the 14-bench callback corpus. If count is 0 and the implementation requires new state-side plumbing, default to wontfix-with-rationale. Extended-string family verified: strtok / strpbrk / strspn / strcspn / strrchr — zero appearances. strncpy is the only extended-string proc that fell back (2 occurrences across google2016_unbreakable_0 and unmapped_analysis; native handler delegates to Python on symbolic length/dst/src).
invariant-native-stdin-readers-use-mint-stdin-bytes
remembered
Harness-seeded stdin (posix.stdin.content -> fd 0 content_sym, angr-mb09c) is consumed by exactly ONE helper: procedures/stdin_common.rs::mint_stdin_bytes. It mints the plain 8-bit leaf symbols, eq-binds the seeded prefix by constraint (never stores the seed AST into the guest buffer -- see avoid-storing-extract-asts-in-guest-buffers), and record_stdin_symbol's ONLY the bytes past the seed (recording seeded ones makes inject_rust_stdin append a duplicate copy of the harness's chunk). Callers: read.rs::read_stdin_symbolic, fgets.rs (fgets/gets/fgetc/getchar), scanf.rs::do_scanf %s path when record_stdin, syscalls/cgc.rs::NativeReceiveSyscall. INVARIANT: any NEW native reader of fd 0 must call mint_stdin_bytes rather than RustBV::symbolic + record_stdin_symbol, or harness-seeded stdin silently regresses to zeros + a zero prefix in posix.dumps(0). Known gap (angr-ptf54): scanf numeric conversions (%d et al.) do NOT consume the seed -- no byte-for-byte mapping exists through a decimal parse, so a seeded harness whose target scanfs %d still gets a fresh symbol and leaves the seed unconsumed. COMPANION (angr-9ke6b.158, commit 24a118286): the NON-stdin side of the same shape lives in syscalls/mod.rs -- syscalls::mint_symbolic_bytes (mint N fresh bytes + store into a buffer; used by read_symbolic, scatter_symbolic, cgc random, NativeGetrandomSyscall) and syscalls::fresh_byte_names (the '_' naming half, one counter bump per batch). CGC receive uses fresh_byte_names + mint_stdin_bytes: it needs the names BEFORE the bytes exist, which is exactly why naming is split out of mint_symbolic_bytes. A new fd-0 reader therefore combines both helpers; a new non-fd-0 buffer-filler uses mint_symbolic_bytes alone and must NOT hand-roll the counter/format!/store loop.
invariant-native-string-null-exists-constraint
forgotten
Native string procs (strlen, strcmp/strcasecmp) must assert a null terminator EXISTS in the scanned window — angr's Python procs get this pruning free from state.memory.find (procedures/libc/strlen.py asserts the searched byte is present within max_str_len). Helper: procedures/strings.rs::null_exists_constraint (Or over the symbolic bytes collected by scan_for_null_symbolic / the s1 side of compare_bytes). It declines when the window holds a concrete null (trivially true) or holds NO symbolic byte (trivially false — would wrongly kill the state). ONLY sound for the procedure's own MAX window: strnlen legitimately saturates at maxlen and strncmp(a,b,4) may compare 4 non-null bytes, so bounded variants pass require_null=false. Landed commit e370c8331 (angr-sgcye).
invariant-native-string-scan-python-bounds
remembered
Native string procs (strlen.rs, strcmp.rs, strchr.rs and the memchr/memcmp variants) enforce Python's symbolic-scan budget, not just its pruning constraint. Two SEPARATE Python-parity mechanisms, both now present, do not confuse them:
(1) PRUNING — strings::null_exists_constraint asserts 'a null exists in the scanned window', the constraint Python gets free from memory.find (smart_find_mixin._find_process_cases adds Or(*cases)). Landed under angr-sgcye. Called by strlen.rs::scan_for_null (require_null) and strcmp.rs (only when stop_at_null && max_len >= MAX_STRCMP_LEN — a caller-bounded strncmp(a,b,4) may legitimately compare four non-null bytes, so it must NOT assert this).
(2) WINDOW — strings::MAX_SYMBOLIC_SCAN_BYTES = 60, mirroring state.libc.buf_symbolic_bytes (angr/state_plugins/libc.py), which Python passes to memory.find as max_symbolic_bytes; smart_find_mixin._find_iter_items simply RETURNS once that many symbolic chars have been yielded. Enforced inside strings::scan_concrete_then_collect (angr-gorvf.5, commit c94941fff) so every scanner that shares the skeleton inherits it. ONLY symbolic positions draw down the budget — concrete filler is free, exactly as in Python.
WHY THE WINDOW MATTERS: without it a fully-symbolic buffer collected up to MAX=4096 positions, so the ITE chain AND the null_exists_constraint disjunction ran thousands of terms wide, and each term kept a downstream branch feasible -> fork-storm where Python deadends. Pruning alone does NOT fix this; a 4096-wide Or is barely a constraint.
IMPLEMENTATION NOTE: the budget needed no signature change to scan_concrete_then_collect's 6 call sites — the existing concrete closure already answers ConcreteStep::BeginCollect exactly for the loads a given scan treats as symbolic, so it doubles as the symbolic predicate during the collect phase.
KNOWN REMAINING DIVERGENCES (both benign, neither a fork-storm driver): Python strlen.py swaps a depth>3 result for a fresh BVS + equality constraint (native returns the raw ITE chain — AST-size cost only); and Python's total search window is max_str_len=128 with doubling, while native still scans concretely to 4096 (strictly more permissive, so it cannot wrongly kill a state).
NULL_EXISTS_CONSTRAINT DETAILS (absorbed in the R4 merge): the helper (procedures/strings.rs::null_exists_constraint, an Or over the symbolic bytes collected by scan_for_null_symbolic / the s1 side of compare_bytes) declines when the window holds a concrete null (trivially true) or holds NO symbolic byte (trivially false - would wrongly kill the state). ONLY sound for the procedure's own MAX window: strnlen legitimately saturates at maxlen and strncmp(a,b,4) may compare 4 non-null bytes, so bounded variants pass require_null=false. Landed commit e370c8331 (angr-sgcye); Python-parity source is procedures/libc/strlen.py's state.memory.find assertion.
invariant-native-strncpy-symbolic-semantics
remembered
Native strncpy now serves a symbolic source window (angr-gorvf.13, procedures/strcpy.rs::strncpy_symbolic). Semantics deliberately mirror angr's Python strncpy, NOT POSIX: cpy_size = ITE(ULT(n, strlen(src)+1), n, strlen(src)+1) and memcpy that many bytes, so dest bytes past the copy length keep their PRIOR contents — no zero padding. The pre-existing all-concrete path still zero-pads (POSIX), so the two paths differ on the tail; that asymmetry is intentional (each matches what it matched before / what Python does) and is covered by test_strncpy_symbolic_source_null_truncates_copy. Reuse notes: the symbolic length comes from strings::build_strlen_chain and the bounded conditional store from mem_common::symbolic_size_conditional_store (the same primitive memcpy/memset use for a symbolic size) — do not hand-roll either. The concrete fast path still makes a SINGLE scan: the symbolic re-scan only runs after scan_concrete_bounded reports a symbolic byte.
invariant-native-syscall-dispatch
remembered
NativeSyscallRegistry (native/angr/src/syscalls/mod.rs) mirrors NativeProcedureRegistry: HashMap keyed by (arch_name, syscall_num). Wired into exploration/stepping.rs at the RunResult::Syscall arm — try native dispatch before creating PendingCallback. Three outcomes: SyscallOutcome::Continue{ret} writes ret to return_register; SyscallOutcome::ContinueSymbolic{ret} writes the BV directly with set_register_by_offset (no concrete wrap); SyscallOutcome::Exit pushes to STASH_DEADENDED. All honor deferred_forks via process_deferred_forks_into. Handler returning Err falls back to the Python callback path (see invariant-syscall-fallback-on-collision for the brk-style 'pre-empt before mutate' pattern). SyscallOutcome derives Debug (needed for #[test] expect_err). Registry covers AMD64/X86/ARM/ARM64/MIPS32/MIPS64/CGC tables — register_syscalls! macro at mod.rs:116. See mod.rs lines 288 (AMD64), 394 (X86), 483 (ARM), 578 (ARM64), 651 (MIPS32), 736 (MIPS64), 819 (CGC) for live lists; do NOT enumerate them in memory (drifts within weeks).
invariant-native-syscall-pc-contract
remembered
Native syscall handler PC contract: by the time RunResult::Syscall fires in stepping.rs, the interpreter has already advanced PC to the post-syscall instruction (set in exits.rs::handle_exit before returning BlockResult::Syscall). So a Continue-outcome handler does NOT need to advance PC — it only needs to set the return register. This matches what Python does after _handle_syscall_callback resolves.
invariant-native-technique-discard-semantics
remembered
INVARIANT + test gotcha for apply_native_techniques' removal arms (exploration/native_technique.rs). (1) Discard semantics are ASYMMETRIC and easy to get wrong: LengthLimiter honors its OWN per-technique 'drop' flag (drop=true discards, and register_length_limiter then never even creates the 'cut' stash), while LoopBound ignores any per-technique flag and honors the MANAGER-wide sm.drop_terminal_states() instead — its discard_stash is always pre-created by register_loop_bound, so under drop_terminal_states it exists but stays empty. Timeout has no drop path at all. Do not 'unify' these without checking Python-side parity. (2) Both removal arms collect indices ascending then remove with to_remove.iter().rev(); a count-only test passes under forward iteration, so assert surviving state_ids IN ORDER with >=2 non-adjacent removals. (3) Timeout arms start_time lazily inside apply_native_techniques (get_or_insert_with(Instant::now)), so a register_timeout(0.0) test races clock resolution — backdate the field via Instant::now().checked_sub(..) before asserting expiry (see expire_timeouts in helpers_tests.rs).
invariant-native-unmapped-segfault
remembered
A native SimProcedure's Memory(Unmapped) decline is NOT always a fallback: under STRICT_PAGE_ACCESS (RustSimState::enforce_permissions) Python's PrivilegedPagingMixin._initialize_page raises SimSegfaultException on the first touch of an unmapped page, so re-running the proc in Python can only reproduce the fault. core_outcome_handlers.rs::segfault_message + NativeProcDisposition::Segfault turn it into CoreReturn::Errored(state, '{page_addr:#x} (unmapped)') — page-ALIGNED, matching Python's pageno*page_size message. Gate is mandatory: with STRICT_PAGE_ACCESS OFF Python lazily inits the page and keeps executing, so those declines must still bounce. Only Unmapped is mirrored (not Permission / UnmappedPageInRegion). Retired unmapped_analysis's 2 simproc bounces (strncmp+strncpy) with byte-identical errored states.
invariant-native-whitespace-single-source
remembered
invariant-ctype-mirror-python-not-rust-std sibling risk is now RETIRED: strtod.rs (floating_prefix_len + the endptr rescan in run_strtod) and strtol.rs (parse_concrete_prefix) no longer use Rust is_ascii_whitespace. All whitespace classification in native/angr/src/procedures/ now goes through the single pub(crate) helper procedures::ctype::is_c_space (' ' plus 0x09..=0x0d), used by NativeIsSpace too — grep for is_ascii_whitespace to verify no new site regresses. SEMANTICS DECISION (angr-2j9sk, commit 2ce6e098c): native strtol/strtod match libc, NOT Python. angr/procedures/libc/strtol.py skips no leading whitespace at all and says so in a source comment ('note: this does not handle skipping white space'), i.e. an acknowledged gap rather than a modelled behaviour — so 'mirror Python' does not apply where Python has no behaviour to mirror. The mirror-Python rule governs ctype PREDICATES, where Python encodes a deliberate predicate.
invariant-nativesimprocedure-name-static
remembered
NativeSimProcedure::name() returns &'static str. For Python-registered procedures whose name is dynamic, store the String once on the wrapper and use Box::leak to produce a 'static reference. The leak is bounded — one allocation per registered procedure, lifetime of process — and the registry holds an Arc for the manager's lifetime anyway. See native/angr/src/procedures/python_proc.rs leaked_name().
invariant-neon-routing-tests
forgotten
When implementing a previously-unimplemented NEON opcode (moving from parse_neon_unimplemented to parse_vector/parse_float with a new IROp variant), update the scaffold test in native/angr/src/vex/opcode_map_tests.rs. As of angr-dondi (2026-06-19) NO NEON op routes through IROp::NeonUnimplemented anymore — the last placeholder Iop_PwAdd32Fx2 graduated to IROp::VFPwAdd in cudgw.6. The routing test was repurposed: fn test_neon_unimplemented_scaffold_is_empty (renamed from test_neon_unimplemented_routing) now asserts Iop_PwAdd32Fx2 parses to VFPwAdd{F32,2} (graduation guard). When you next park a NEON op via parse_neon_unimplemented (returns None today), add a positive routing assertion there. Add matching assertions to test_neon_does_not_shadow_existing_mappings to lock in new IROp variants.
invariant-neon-scaffolding-panic-not-fallback
forgotten
ARM/AArch64 NEON SIMD ops use a NeonUnimplemented(&'static str) scaffold (vex/ir.rs, commit 48f949fde). Unmapped NEON Iop names route through opcode_map.rs::parse_neon_unimplemented() into the variant, and VEXOps::unop/binop/ternop/qop PANIC on dispatch with 'NEON op X not yet implemented'. This is intentional — the default Raw(0) path silently returns a fresh-symbolic result, which masked missing NEON coverage for months. Why: angr-bkcs (the parent epic) auto-deferred after 3 dirty iterations because the silent-fallback model produced wrong results that were hard to attribute. How to apply: when implementing a NEON op in angr-bkcs.2, (1) remove its entry from parse_neon_unimplemented in opcode_map.rs, (2) add the mapping to parse_vector (or parse_float) emitting a real IROp variant, (3) wire that variant into VEXOps::unop/binop. Do NOT delete the NeonUnimplemented variant or its panic arms — they protect every still-unimplemented NEON op.
invariant-neon-unimplemented-not-panic
forgotten
After angr-tkbr.3 (commit a4ab7e64e, 2026-05-25): IROp::NeonUnimplemented dispatch in VEXOps::unop/binop/ternop/qop returns OpError::UnsupportedNeon{name: &'static str} INSTEAD of panicking. interpreter_cb/expressions.rs unop/binop/triop/qop arms special-case this variant via Err(e @ OpError::UnsupportedNeon{..}) => Err(CbExecutionError::Op(e)) BEFORE the generic Err() silent fallback, so missing NEON coverage still surfaces (now as RustUnsupportedVexOpError or RunResult::Error rather than PanicException). The invariant from invariant-neon-scaffolding-panic-not-fallback stands: do NOT add UnsupportedNeon to the silent-fallback Err() arm and do NOT remove the IROp::NeonUnimplemented variant. When implementing a NEON op (angr-bkcs.2 children): (1) remove from parse_neon_unimplemented in opcode_map.rs, (2) add to parse_vector/parse_float, (3) wire into VEXOps. Tests in TestRustExecutionErrorHierarchy (tests/engines/test_rust_exploration.py) cover the typed-exception path.
invariant-never-prime-sat-cache
remembered
angr-3ag1l root cause: RustExplorationManager::resume_after_symbolic_branch (native/angr/src/exploration/resume.rs) used to call set_sat_cache(true) on true_state/false_state, citing a can_be_true/can_be_false proof from the interpreter. That proof does NOT exist on this path: statements.rs IRStmt::Exit under !use_deferred_forks returns StmtResult::SymbolicBranch WITHOUT calling check_branch_feasibility (it deliberately skips the Z3 checks and defers constraint-adding to Python resume). The primed cache then satisfied every downstream satisfiable() gate (route_successor's gate_found_on_sat, the is_find push in run_loop step_one), so an UNSAT state landed in the found stash. INVARIANT: never prime sat_cache from a feasibility claim you did not just verify -- only is_sat()/eval() may write Some(true).
invariant-newtype-impl-into-inference
remembered
Public Rust APIs that accept newtype-wrapped values (Address(u64), StateId(u64), etc.) should take 'impl Into'. Rust's literal-type inference picks u64 automatically as long as From is the only conversion in scope, so call sites with plain integer literals like 'mem.map(0x1000, ...)' compile unchanged. This sidesteps the deferred-borb concern about every caller needing '.into()' or 'Newtype(...)' wrapping. Verified for memory::Address (angr-borb.1, 2026-05-21).
invariant-no-ip-concretization-routing
forgotten
NO_IP_CONCRETIZATION semantics (Python engines/successors.py:292-296): when set, max_targets is forced to 0 with skip_max_targets_warning=True, so the cond_and_targets list length always exceeds max_targets and the state goes to unconstrained_successors silently. Rust mirrors this in eval_next_addr_concretized (interpreter/exits.rs): the short-circuit fires for ANY symbolic IP (after the concrete fast-path) BEFORE the ITE fast-path and the AddressConcretizer — even ITE-derived concrete-target sets are skipped, matching Python's 'if next_val.is_symbolic()' upstream check. Default exit only; Exit statements mid-block go through eval_next_addr which falls back to Python on symbolic addresses BEFORE the no_ip_concretization check would matter.
invariant-no-macros-in-pymethods
remembered
PyO3 REJECTS macro_rules! invocations inside a #[pymethods] impl body: 'error: macros cannot be used as items in #[pymethods] impl blocks (note: this was previously accepted and ignored)'. pyo3 0.27.2. This blocks the obvious DRY fix for any family of near-identical pymethods wrappers (hit on angr-9ke6b.204, the ~25 op_* binary-op wrappers in native/angr/src/solver.rs).
Two escapes and why only one is viable here: (1) macro-generate a SECOND '#[pymethods] impl' block -- needs the pyo3 'multiple-pymethods' feature, which this crate does NOT enable (see CLAUDE.md Key Files note on exploration/manager_methods.rs, split out for exactly this reason). (2) keep the wrappers hand-written but move their shared BODY into a private helper on a plain (non-pymethods) impl block, dispatching by fn pointer. This is what RustSolverContext::binop does: takes 'op: SymbolTableBinOp = fn(&RustSymbolTable, u64, u64, &SymContext) -> Result<RustBVHandle, BinaryOpError>', so each wrapper is one line 'self.binop(a_id, b_id, RustSymbolTable::op_add)'. 26 wrappers x 8 lines -> 26 x 3.
Silver lining: pyo3 ERRORS rather than silently dropping the methods from the Python type, so the dangerous failure mode (compiles, but hasattr(ctx,'op_add') is False) cannot happen. Verified anyway via tests/engines/rust/test_solver_ops.py, which getattr-s the op names from Python.
invariant-no-native-simactions
forgotten
angr-op0dn.14.3 (2026-07-14): the Rust engine will NOT grow native SimAction recording — SimActions wrap claripy objects Python-side and no default mode bundle sets TRACK_*_ACTIONS, so the default-engine flip never needs them. The five recording-gating options (TRACK_MEMORY/REGISTER/TMP/JMP/OP_ACTIONS) live in _RAISE_OPTION_NAMES (rust_manager.py), which makes them ROUTING signals: rust_engine_eligible() -> ineligible -> simulation_manager(use_rust_engine=None) hands back a plain Python SimulationManager with state.history.actions populated as usual. Explicit use_rust_engine=True still raises NotImplementedError (loudness preserved). TRACK_ACTION_HISTORY is deliberately NOT in that set (demoted angr-fkvt) — it does not gate recording. Coverage: TestActionTrackingRoutes in tests/engines/rust/test_auto_dispatch.py. Parity floor for history-driven workloads on Rust = RustHistoryProxy (bbl_addrs/recent_bbl_addrs/block_count/jumpkind); history.jumpkinds list form has zero consumers, do not build it speculatively.
invariant-no-return-deadend
remembered
Native SimProcedure dispatchers MUST honor no_return. If they advance PC to return_addr after a NO_RET proc like exit, the state re-executes past the call site. In fauxware, exit's return address (0x40071d) coincides with main's prologue, so the state re-enters main -> infinite loop. Fix: when no_return is true, skip the PC/SP fixup, run process_deferred_forks_into to keep unexplored branches, then push the main state to STASH_DEADENDED instead of returning it as a successor. LIVE DISPATCH SITES (citations repaired 2026-08-02; the old 'stepping.rs:handle_simprocedure' and 'mod.rs:run top-of-loop hook check' no longer exist): (1) the hook block in exploration/run_loop_single.rs and (2) exploration/core_outcome_handlers.rs::handle_simprocedure_core. The two take the flag from DIFFERENT sources -- see invariant-no-return-two-dispatch-paths.
invariant-no-return-two-dispatch-paths
remembered
no_return flows through TWO independent paths in the Rust engine and they deliberately disagree on the source of the flag. (1) Serial step_one path: run_loop_single's hook block reads the Python-registered flag out of StepContext::simprocedures (the 4-tuple from RustExplorationManager::register_simprocedure) and passes it to dispatch_native_proc. (2) Core/parallel path: core_outcome_handlers::handle_simprocedure_core ignores the registration and uses NativeSimProcedure::no_return() instead. dispatch_native_proc's doc comment enumerates this as one of the caller-owned differences. Consequence: for a hook where the Python SimProcedure's NO_RET disagrees with the native proc's no_return(), the two paths terminate the state differently. The VEX interpreter itself carries NO no_return (SimProcedureInfo has no such field since angr-9ke6b.218 item 2, and RunResult::SimProcedure has no member for it) -- so do NOT 'fix' this by threading the flag through the interpreter; that silently picks one of the two answers. Related: invariant-no-return-deadend (whose stepping.rs:handle_simprocedure / mod.rs:run citations are STALE; the live sites are run_loop_single's hook block and handle_simprocedure_core).
invariant-no-rust-mgr-calls-in-step-callbacks
remembered
INVARIANT: a Python inspect endpoint fired from INSIDE a Rust step must not call back into self._rust_mgr — the pyclass is mutably borrowed for the duration of the step, so e.g. get_state_ids('active') raises RuntimeError('Already mutably borrowed'). Hit in angr-op0dn.14.4.2: _cb_inspect_vex_lift resolved its state_id=-1 sentinel ('lifts are state-independent') via get_state_ids, which worked only because the pre-existing Python-lift dispatch was exercised out-of-step in tests. Fix: resolve the representative state with the borrow-free thread-local angr.rustylib.vex_engine.get_stepping_state_id() (cached as self._get_stepping_state_id in _setup_callbacks), keeping the stash query as the out-of-step fallback.
invariant-no-silent-symbolic-coercion
forgotten
INVARIANT: any symbolic value entering Rust dispatch logic must NOT be silently coerced to a concrete fallback (e.g. .unwrap_or(0)). Audit pattern: search for .as_u64().unwrap_or, .as_u128().unwrap_or in dispatch/control-flow code paths. Each one is a candidate for silent divergence from Python. The angr-gffd bug was exactly this — get_syscall_num collapsed symbolic syscall numbers to 0, silently dispatching to read on amd64. Pattern to use instead: return Option/Option and force Python fallback on None. Honored SimOptions in Python (e.g. NO_SYMBOLIC_SYSCALL_RESOLUTION) get respected automatically when the symbolic case is routed back to Python.
invariant-no-state-store-after-proxy-install
remembered
INVARIANT: any state.memory.store() / state.memory.load() in code that runs during _create_state_for_callback (rust_callback_dispatch.py:~2150) must be guarded with _is_rust_memory_proxy(state.memory) early-return when the operation is a 'Rust->Python copy' (replay/sync from pending Rust state). Reason: on the 2nd+ callback for a cached state, state.memory IS RustMemoryProxy from the prior install (line 2231). A 'sync to Python' store then round-trips through set_state_memory_concrete and clobbers symbolic_objects in Rust. Two known sites guarded in commit 2480b2aca: _replay_rust_dirty_pages, _install_rust_memory_proxy (both in rust_state_sync.py). Audit any new sync helpers added to _create_state_for_callback's prologue for the same pattern. Pattern in code: from angr.exploration.rust_manager import _is_rust_memory_proxy; if _is_rust_memory_proxy(state.memory): return
invariant-no-ticket-prefixed-comments
forgotten
P/GAP-prefixed comments were stripped (commit 70b8a89bb). The rust-symex bringup left ~50 inline labels like 'P1 fix:', 'P9:', 'GAP 5:' in comments, docstrings, and log strings across angr/exploration/*.py. They referred to ticket numbers from the rust-engine-v2 → rust-symex port. Per CLAUDE.md ('Don't reference the current task, fix, or callers'), the prefixes were removed while keeping the substantive explanation text. Do NOT reintroduce ticket-prefixed labels in new code — write what the code does and why, not which ticket motivated it.
invariant-no-z3-stub-export
forgotten
When adding #[cfg(not(feature = 'vex-engine-z3'))] stubs in symbolic/context.rs for methods that observe constraints (assume_true, assume_false), the stub must still push to assumed_constraints_local. That field and assumed_constraints_shared are intentionally NOT z3-gated (defined at lines 217-218 of context.rs without #[cfg]), specifically so the Python export path get_assumed_constraints() works in both feature combos. If you skip the push in the no-z3 stub, callers that rely on constraint export — e.g. the Rust→Python sync path — will silently lose constraints in no-z3 builds. check_branch_feasibility's no-z3 stub returns (true, true) for symbolic values, matching the existing can_be_true/can_be_false stubs that assume both directions feasible. Resolved in commit 97cf549ea (angr-l85c).
invariant-no-zero-fill-symbolic-store
remembered
Symbolic-store zero-fill is a hard error, not a fallback (angr-9ke6b.19, commit 69c0ef344). bv_to_bytes (both copies: interpreter/helpers.rs and callbacks/dispatch.rs) returns all-ZERO bytes for a symbolic RustBV, so any 'fall back to the byte-level memory_store callback' path is sound ONLY for concrete values. Four sites used to take it unconditionally when memory_store_symbolic_value was unwired, reporting success while overwriting memory with 0: call_memory_store_symbolic_value's own fallback, statements.rs::handle_storeg (concrete-addr AND concretized-Single arms of the symbolic-guard StoreG path), statements_cas.rs::cas_store_symbolic_data (concrete addr), statements_store.rs::handle_symbolic_store (Single arm). Now all four reject a symbolic value: interpreter/helpers.rs::reject_symbolic_byte_store(value, addr, site) -> CbExecutionError::Unsupported is the single rule for the three interpreter sites, and call_memory_store_symbolic_value raises PyRuntimeError on its own path. Concrete values still take the byte fallback unchanged. Rule for new store paths: never hand a possibly-symbolic RustBV to bv_to_bytes + call_memory_store — either route through call_memory_store_symbolic_value / _symbolic_full, or call reject_symbolic_byte_store first. Latent in production (rust_manager.py::_setup_callbacks always registers the callback), live for minimal embeddings and bare PythonCallbacks::new() test harnesses. Sibling invariant on the buffering side: invariant-symbolic-store-no-zero-bytes. Pinned by dispatch_tests.rs::store_symbolic_value_without_callback_errors_instead_of_zero_filling and statements_tests.rs::cas_symbolic_store_without_callback_errors_not_zero_fills.
invariant-non-exhaustive-policy
forgotten
API stability via #[non_exhaustive]: marked on 9 public Rust types in angr-irwe (2026-06-01). 5 error enums (BridgeError, SyscallError, OpError, ProcedureError, MemoryError), 2 event enums (CallbackReason, ExecutionEvent), 2 pyclass structs (ExplorationEvent, ExecutionConfig). RustExecError already had it from tkbr.3. Internal-only enums (CbExecutionError, LiftError, DeserializeError, NativeLiftError, ConstraintSyncError, ExtractionError) intentionally NOT marked — they don't reach Python. Audit found ALL intra-crate match sites already use a _ wildcard arm (run_loop:567 has explicit _ => for CallbackReason; profiling at run_loop:335 for ProcedureError; same pattern across syscalls). Pre-existing struct-literal sites for ExplorationEvent (run_loop.rs:119,163) and ExecutionConfig (callbacks.rs:166) are intra-crate so #[non_exhaustive] does not affect them. Semver contract documented in docs/advanced-topics/rust_engine.rst: patch=no API change; minor=add exception subclass + pyclass get/get-set fields + enum variants; major=rename/remove. Python exception hierarchy intentionally has NO exhaustive-match guarantee — downstream pytest.raises code keeps working when new subclasses are added (they're new subclasses of RustExecutionError).
invariant-non-ffi-unsafe-safety-comments
forgotten
Unsafe-hygiene audit closure (angr-inieg.4, commit 71ac911bd): every unsafe block under native/angr/src/ now carries a SAFETY comment — 58 sites as of 2026-07-14. The last gap was engine.rs::get_z3_global_param (Z3_global_param_get + CStr::from_ptr). When re-auditing, do NOT use a naive 'SAFETY within N lines above' regex: solver.rs::convert (claripy-AST-alive invariant) and symbolic/value_z3.rs fpa_to_bv put the comment >8 lines above the unsafe, and vex/libvex_lifter.rs (feature-gated FFI) relies on a module-level contract. Anchor the audit on symbol names, not line windows.
invariant-nx-block-fetch
remembered
NX (execute) permission is checked at VEXInterpreter::get_or_lift_block (interpreter/execution.rs (fn get_or_lift_block)) via SymbolicMemory::check_executable(addr). Check runs BEFORE the cache lookup so a page whose X bit is stripped after the IRSB was first cached is still rejected. The check is a no-op when enforce_permissions is off OR when the page is unmapped — only mapped-without-X errors. Unmapped pages must Ok() through here so libpyvex region lift / Python lift_block fallbacks can still resolve code from the loader (binary regions are not always materialized in SymbolicMemory pages).
invariant-objectmapper-eq-identity
remembered
ObjectMapper identity in native/angr/src/automaton/python_bindings.rs (angr-9ke6b.187, 2026-08-01; interner extracted in .186, 2026-08-02): identity is bucket-by-hash() + PyAny::eq disambiguation via fn find_in_bucket, NOT the old (hash, repr) tuple key. Invariant for future edits: never reintroduce repr() (or any stringification) as an equality proxy — it silently merges eq-distinct NFA/DFA states/symbols. Both sides now share one struct IdInterner (fields: buckets, interned, reserved, kind); ObjectMapper holds states and symbols IdInterners and its get_or_create_state_id / get_or_create_symbol_id / get_symbol_by_id are thin delegators. StateId and SymbolId are both u32, so one interner serves both; the symbol side's only difference is reserved=Some(EPSILON), which IdInterner::get_or_create turns into the 'Too many symbols' error and IdInterner::get turns into the None round-trip. find_in_bucket stays a free function (not a method) because get_or_create holds a mutable borrow of buckets across the scan; it deliberately propagates a raising eq rather than treating the error as 'not equal'. Test gotcha in python_bindings_tests.rs: py.run() snippets get a locals dict but globals=None, so a class method cannot see its own class name — use type(other) is type(self), not isinstance(other, ClassName).
invariant-offload-serde-budget-gate
remembered
offload_surplus (exploration/scheduler_worker.rs) is unit-testable in isolation: call it directly with a VecDeque, a fresh Injector, an AtomicUsize idle count and a SchedulerCounters::default() whose serde_ns/step_ns are preset via store(). SchedulerCounters' private fields (surplus_offloaded) ARE readable from scheduler_worker_tests.rs because the worker module is a DESCENDANT of the scheduler module that defines the struct — no visibility edits needed. Trigger A gate offload_is_affordable is serde*SERDE_BUDGET_DIVISOR(4) <= step, inclusive, monotone and never reset for session life, so it only reopens once accumulated step time catches up; Trigger B (LOCAL_HWM=64, sheds to HWM/2, strict >) is deliberately NOT budget-gated. Covered by scheduler_worker_tests.rs (angr-ph300.15).
invariant-offthread-dealloc-confined-to-native-workers
remembered
Off-thread pyclass-dealloc leak class is CONFINED to types the parallel scheduler workers actually own/drop, and workers own ONLY native (non-pyclass) data: worker_thread (exploration/scheduler.rs) captures channels (job_rx/done_tx); jobs cross the thread::scope join as native RustSimState (TaskOutcome.continue_states/terminal_states, serialized/'materialized'), NEVER as Py<...> pyclass objects. So among #[pyclass(unsendable)] types (RustSolverContext, RustExplorationManager, PyRustSimState, fuzzer/icicle types) only RustSolverContext was reachable for the cross-thread dealloc leak (fixed angr-87e56/.1). RustExplorationManager+PyRustSimState are #[new]/fork() GIL-thread-constructed and main-thread-dropped (exploration only BORROWS &PyRustSimState via add_state/_add_state); fuzzer/icicle are feature='fuzzer' default-off, not in the parallel build. Investigated angr-1yge9.4 iter67.
invariant-on-state-removed-notify-sites
remembered
on_state_removed hook (exploration/selection_policy.rs SelectionPolicy::on_state_removed) must be called at EVERY STASH_ACTIVE removal path outside policy.select, else a memoizing policy (LoopHeadRoundRobin key_cache) leaks a memo entry per removed state. Full inventory of notify sites after angr-myzjx.25: PARALLEL — scheduler_worker.rs offload_surplus + drain, run_loop.rs wave-mode drain + seed_steady_session_from_active. NON-PARALLEL — helpers.rs apply_uniqueness_filter, apply_native_techniques (Timeout/LengthLimiter/LoopBound arms); state_lifecycle.rs _move_states (no-filter + filter branch), _move_state, _reset_for_stage (dropped ids); manager_methods.rs drop_state_from_stash; stepping.rs _step_state. Stash-generic sites (_move_states/_move_state/drop_state_from_stash/_step_state) guard on STASH_ACTIVE (via from_stash param or sm.stash_of before take). Any NEW active-removal path must add a notify — grep for on_state_removed to find the pattern. Not a correctness bug (state_id never reused, key stable) — pure over-session memory leak.
invariant-opcode-map-ground-truth-is-vendored-header
remembered
Before adding or trusting an IROp arm in native/angr/src/vex/opcode_map.rs, grep native/angr/vendor/pyvex_ffi.h for the exact Iop_ string — it is the vendored libVEX opcode enum and therefore the ground truth for what a real lift can emit. Two whole families were dead code that read as implemented coverage until angr-9ke6b.161/.162 (commit bad76646f): scalar Iop_MulHi{32,64}{S,U} (only vector Iop_MulHi{U,S}x exist; x86 IMUL/MUL lift to Iop_MullS32/Iop_MullU32 + Iop_64HIto32) and scalar Iop_Neg{8,16,32,64} (only float Iop_Neg{F16,F32,F64,F128} -> IROp::FNeg and vector Iop_NegFx exist; integer negation lifts as 0-x via Iop_Sub). The tell for this class: an opcode_map arm whose IROp variant has zero references in any _tests.rs — parse_opcode falls through to IROp::Unmapped for a name nobody emits, so the arm never fires and no test ever notices. The QDMulHi/QRDMulHi high-half vector family is genuinely unmapped (see invariant-qdmull-vex-op), which is a different thing from unreachable.
invariant-orchestrator-dryrun-side-effects
forgotten
run_optimization_loop.py --dry-run runs in CI (orchestrator_smoke job in .github/workflows/ci.yml). It MUST NOT acquire side effects: pidfile, systemd scope cleanup, prereq binaries (claude/bd/systemd-run). Order in main(): logging → signal_handlers → dry_run-exit. Side effects (prereq check, systemd-run probe, _acquire_pidfile, _cleanup_stale_scopes) come AFTER the dry_run return. If you re-add side effects before the dry_run return, CI smoke job will fail on a clean checkout.
invariant-pabs-int-min-passthrough
forgotten
Iop_Abs{8,16,32,64}xN: per-lane abs preserves INT_MIN (i.e. |INT_MIN| = INT_MIN under two's complement). Both Rust hardware behavior and Z3 BV symbolic path get this for free since (~x + 1) on a fixed-width N-bit value rolls back to INT_MIN. Don't add an extra ITE checking for INT_MIN — it'd break agreement with PABS*.
invariant-page-bitmap-authoritative
remembered
Page bitmap is the AUTHORITATIVE source of truth for byte-level symbolic-vs-concrete state in SymbolicMemory, NOT symbolic_objects/symbolic_spans. The sidecar maps (symbolic_objects, symbolic_spans, multi_objects) are caches/indexes that can lag behind page-bitmap-driven state. store_concrete's page.store_concrete correctly clears the bitmap bits but does NOT clean wider-sym sidecar entries based at a different address. Any load fast path that returns a sym (or extract) without first verifying the bitmap is buggy. See bytes_all_marked_symbolic helper (memory/mod.rs angr-jvjf) for the bitmap consultation pattern.
invariant-parallel-bounce-reenterable-pc
remembered
Parallel worker bounce states carry pc=0 unless stamped. parallel_process_state (run_loop_worker.rs) runs the single-threaded state-update preamble state.set_pc(step.new_pc) BEFORE classifying the CoreReturn; for a Hook / SimProcedurePython bounce step.new_pc is 0. dispatch_bounce re-stamps the pc for exactly those two kinds, so the normal bounce path never noticed — but ANY other consumer of a bounce state (route_materialized_terminal's untagged-residual None arm, flush_parked_bounces_to_active at snapshot time) inherits pc=0 and the state dies at its next lift ('Lift error at 0x0: No bytes in memory'), silently taking its whole subtree with it. Fix (angr-op0dn.13.11): the NeedsPython arm of parallel_process_state stamps bounce_target_addr(&kind) onto the bounce state — AFTER materialize_bounce_forks (so forks are still derived from the unmodified base, bit-identical to single-threaded) and before it crosses the join. Invariant for new code: a bounce state that can reach a stash must carry a re-enterable pc; kinds with no entry address (SyscallPython, SymbolicBranch) still carry new_pc and must never be routed to STASH_ACTIVE.
invariant-parallel-found-return-flush-bounces
remembered
run_loop_parallel + run_loop_parallel_steady found early-return (found_count()>=num_find at loop top) must call flush_parked_bounces_to_active BEFORE returning ExplorationEvent::found, else a bounce tail parked in pending_parallel_bounces by a prior wave (states in NO stash) is stranded — active_count() under-reports vs the serial loop which leaves the equivalent frontier in STASH_ACTIVE. Steady loop flushes AFTER finalize_steady_session so the resident-id guard in flush_parked_bounces_to_active sees the drained frontier and never double-inserts. Distinct trigger from angr-05kiw (single-threaded fallback stranding). Fixed angr-ph300.8, commit cd8165d1d.
invariant-parallel-post-cancel-test-timing
remembered
angr-vplge de-flake: parallel post-cancel test assertions must not assume worker timing. Two patterns to reuse. (1) Rust: in exploration/scheduler_tests.rs, never sleep() a peer task and hope a finder's cancel lands inside the window — a worker never dispatches a NEW task once CancelToken trips (scheduler_worker.rs checks it at the task boundary), so post_cancel_steps can only be incremented by a peer that was ALREADY in-flight. Use the spin_until() helper: finder holds until run_order>=2, peer holds until cancel.is_cancelled(). (2) Python: in tests/engines/rust/test_parallel_wave.py, any assertion whose magnitude depends on the RESIDUAL FRONTIER WIDTH at a num_find cancel point is load-sensitive on the parallel arm — which worker wins the race to the target decides how much frontier the others still hold. test_snapshot_after_cancel_round_trips_and_resumes' >=4-of-8 resumed-leaf bound is now serial-arm only; the parallel arm keeps only the no-overshoot upper bound. The worker-count-invariance contract lives in test_resume_from_fixed_snapshot_is_worker_count_invariant, which holds snapshot BYTES fixed and so has no dump-width variance.
invariant-parallel-terminal-summaries
remembered
Parallel terminal accounting (angr-op0dn.13.15): a Rust parallel worker NEVER serializes a dead path back to the coordinator — run_post_step_core's dead outcomes become TerminalSummary records that are counted and dropped in the worker's own Z3 context (scheduler_worker.rs worker_wave_loop / worker_session_loop -> SchedulerCounters::record_summaries). Consequence, by design: mgr.stash_counts()['deadended'] is ALWAYS 0 under RUST_PARALLEL_WORKERS>1 — the terminal STATES are unrecoverable; only the COUNTS survive (folded into sm.deadended_count/errored_count/pruned_count by fold_scheduler_dispatch_stats in exploration/helpers.rs, which both parallel loops call exactly once per run). Any test comparing serial vs parallel terminal behavior must project on stats()['deadended_count'], never on the deadended stash. Callers needing the dead states must run single-threaded.
invariant-parallel-worker-dispatch-counters
remembered
Parallel dispatch counters (angr-op0dn.13.9): parallel_width_hist / parallel_max_active_width are now sampled at worker::dispatch_next (scheduler_worker.rs) from WorkTransport.pending, so BOTH the wave and steady loops record width — previously only the serial migration model (record_migration_sample, helpers.rs) did, and every parallel run reported width 0. parallel_worker_dispatch is a real per-worker dispatch vector (SchedulerCounters.worker_dispatches, fixed-size MAX_TRACKED_WORKERS=32) folded by RustExplorationManager::fold_scheduler_dispatch_stats. PITFALL: trim that vector by parallel_real_workers (the actual pool size), NOT parallel_num_workers — the latter is the MIGRATION MODEL's modelled worker count (defaults to 4, independent of the real pool), and sizing by it pads phantom zero-dispatch slots that make the gate's max/min balance ratio a spurious inf.
invariant-parallel-worker-dispatch-folded
remembered
stats()['parallel_worker_dispatch_folded'] (angr-9ke6b.67) is the degraded-data marker for stats()['parallel_worker_dispatch']: non-zero means RUST_PARALLEL_WORKERS exceeded scheduler::MAX_TRACKED_WORKERS=32, so SchedulerCounters::record_dispatch folded the tail worker ids into the last slot and the per-worker max/min load-balance ratio the find-all gate reports is NOT trustworthy. record_dispatch also emits a one-time log::warn! on the first fold. 0 on every realistic run (pools are <= num_cpus).
invariant-parity-census-exonerated-ledger
remembered
parity_census.py's _EXONERATED table is NOT auto-derived — it is a hand-maintained ledger, and it silently rots whenever rust_manager.py demotes an option out of _RAISE_OPTION_NAMES. collect() classifies any divergence-risk option that is in neither _RAISE_OPTION_NAMES, _REJECTED_OPTION_NAMES, _EXONERATED, nor the rst 'Honored options' physical table as tier='silent'. _docs_matrix() only subtracts names from that physical table — a divergence-table row whose prose verdict reads '(a) honored' does NOT clear the option. So a demotion that is documented only in a rust_manager.py comment block plus divergence prose lands the option in the flip-gate silent surface, contradicting rust_engine.rst's 'opt-in silent count is now zero'. That is exactly how CONSTRAINT_TRACKING_IN_SOLVER (angr-op0dn.14.2) and EFFICIENT_STATE_MERGING (angr-op0dn.11.6) sat misreported until angr-xz5uu. RULE: any commit that demotes a SimOption out of _RAISE_OPTION_NAMES must add it to parity_census.py _EXONERATED and regenerate parity_census.json in the SAME commit. Guarded since angr-xz5uu by the nightly-ci.yml 'parity_census' job, which runs the census (its SystemExit drift guards gate) and then re-emits --json under 'git diff --exit-code' so the checked-in artifact cannot lag the harness.
invariant-parked-bounces-need-flush
remembered
pending_parallel_bounces (RustExplorationManager, exploration/mod.rs) holds LIVE states that live in NO stash — a wave parks its bounce-queue tail there when it surfaces one need_callback. Any new consumer that reads only stashes (snapshot dump, stash_counts, a new export path) must call flush_parked_bounces_to_active() first, and any new run-loop route must drain the queue. As of angr-05kiw (commit 6a980325d) the three callers are dump_snapshot_bytes, finalize_parallel_session, and run_loop_single_threaded (drain sits ABOVE the callbacks-not-set check so even the error exit consumes it); the two parallel loops drain it themselves via process_parallel_bounce_queue. Flush is idempotent (empty-queue early return + resident-id skip), so double-calling is safe. Non-re-enterable kinds (worker zeroed the pc) stay parked and are logged, never dropped.
invariant-partial-overlap-byte-merge
remembered
Fixed 2026-05-08 (angr-3zhl, commit 813dbcce3): load_concrete detects when any other symbolic_objects entry starts inside (addr, addr+size) and falls through to a per-byte merge via symbolic_objects + symbolic_spans (endianness-aware). store_concrete still does NOT split or invalidate wider entries on partial overwrite; correctness now lives entirely on the load side. If you ever need to invalidate eagerly on store, the invariant to maintain is: for every byte in [addr, addr+size), exactly one of {symbolic_objects[byte_addr], symbolic_spans[byte_addr]→sym at non-stale offset} contributes. Regression test: memory::tests::test_load_concrete_partial_overlap_later_store_wins.
invariant-pbounce-leaf-projection
remembered
Leaf/path identity on the pbounce synthetic must NOT be projected as the concrete stdin witness bytes. The find gate is ((acc & 0xff) == 0xee), which leaves the stdin prefix substantial model latitude, so two solvers walking the SAME path legitimately return different satisfying 3-byte prefixes — a test asserting witness-set equality across worker counts (or across a snapshot round-trip) is flaky by construction. Valid projections, in increasing strength: (a) found-state pc set == {target}; (b) leaf COUNT; (c) witness DISTINCTNESS (len({s.posix.dumps(0)[:3] for s in found}) == len(found)) — this catches a run that silently re-collects one leaf twice, which the count alone does not. Used by TestParallelCheckpointFrontier::test_resume_from_fixed_snapshot_is_worker_count_invariant and ::test_resumed_found_states_solve_to_distinct_stdin.
invariant-pending-ancestry-best-effort
remembered
invariant: _get_pending_ancestry (exploration/pending_api.rs) is BEST-EFFORT, not a complete lineage. Rust keeps no state_id->parent map — only RustSimState.parent_id (ONE hop) and StashManager.state_roots (id->root). So the ancestry walk (angr-ph300.25, commit 563e8c772) follows parent links only through states the manager STILL HOLDS: _parent_of checks pending_callbacks (hash lookup) then sm.find_state (stash scan). A fork CONSUMES its parent, so the walk records the parent id it was told about and then stops as soon as that ancestor is gone. Guards: repeated-id break + Self::MAX_ANCESTRY_DEPTH=64 (find_state has a linear all-stash fallback, so an uncapped walk could rescan every stash per hop). The sm.roots() lineage root is always appended when absent, so the result is never SHORTER than the pre-fix [state, parent, root]. Consumers: rust_callback_dispatch.py::_get_pending_ancestry and rust_state_sync.py iterate it to find cached SimState data; they must keep tolerating gaps. If a future need requires a GUARANTEED full chain, the fix is a state_parents map alongside state_roots in stash.rs — but note ~15 sm.set_root call sites and that state_roots already has a leak bead (angr-ph300.26), so a second map inherits the same GC hazard. Regression: pending_api_tests.rs (3 tests).
invariant-pending-api-extension-impl
forgotten
pending_api.rs (native/angr/src/exploration/pending_api.rs) holds the bodies for 35 pyclass-exposed methods that read or mutate pending_callback state (registers, memory, history, jumpkind, dirty pages, constraints, snapshots, solver fork/borrow, skip-hook stack). The pyclass-facing wrappers in mod.rs forward to pub(crate) _method_name bodies here. Same extension-impl pattern as resume.rs/stepping.rs/run_loop.rs. PyO3 0.27 in this project does not enable multiple-pymethods, so each pyclass is limited to a single #[pymethods] impl block — the extension impls in pending_api.rs are plain impl blocks (no #[pymethods]). When changing pending callback semantics, look at pending_api.rs first; mod.rs has only thin forwarders.
invariant-pending-memory-load-no-zero-fabrication
remembered
In exploration/pending_api.rs the three 'load memory as concrete bytes for Python' helpers now agree on refusing to fabricate data for non-concretizable symbolic loads: _get_state_memory (state_api.rs) returns Ok(None); _pending_memory_load returns Err(PyValueError) on both memory-fault (Err arm) AND eval-no-witness (else branch, fixed angr-04tw3.2); _get_pending_memory raises PyValueError immediately (no eval fallback, by design). Never return vec![0u8; size] for a symbolic load — callbacks consuming by length mistake zeros for real data (angr-ph300.19, angr-04tw3.2).
invariant-pending-store-buffer
forgotten
PendingStoreBuffer (interpreter/pending_store.rs) wraps Vec<(u64, Vec)> with a HashMap<u64, usize> mapping each byte address covered by a pending store to the index of the most-recently-pushed store covering it. Lets concrete-addr loads fast-skip the reverse linear scan when no pending store overlaps the load (the common case). Fast path: byte_index hit + store fully covers load -> O(1). Slow path: byte_index hit but store smaller than load -> reverse scan (preserves prior 'most recent fully-covering wins' semantics for the rare smaller-on-top-of-larger case). Index is cleared in drain() and clear() (the former VEXInterpreter::take_all_stores accessor was deleted as dead code in angr-9ke6b.214). Push sites are in statements.rs (the store/StoreG/CAS/LLSC handlers). The VEXInterpreter struct holds it as 'pending_stores: PendingStoreBuffer'.
invariant-pending-writes-defer-then-flush
remembered
PendingWrite path semantics (memory/mod.rs:29 and tests.rs::test_pending_write_visible_after_flush): add_pending_write defers a store; subsequent loads do NOT see it because apply_pending_writes_concrete/_symbolic in load.rs are intentionally stubbed to return base_value (per lazy-memory-load-overlay-fails — overlay was disabled because it didn't work for sym-write). Only flush_pending_writes() materializes the writes via concretize+ITE; only after flush will a re-load see them. fork() clones the pending list via Vec::clone (mod.rs:298), so writes registered pre-fork are visible in both halves once each flushes. Tests pinning these three invariants: test_pending_write_visible_after_flush, test_fork_pending_writes_visible_in_both_after_flush, test_fork_pending_writes_isolation.
invariant-perf-gains-can-widen-bimodal-variance
forgotten
Perf gains on Z3-nondeterministic (bimodal) benchmarks can WIDEN the gap between fast and slow modes rather than collapsing them. Confirmed on google2016_unbreakable_1 (2026-05-22): post-May-18 perf wins (angr-b58a UltraPage memcmp + lazy-region FFI; angr-zdho z3_ast cache; angr-9jly proxy fast-path) moved the fast mode from ~2.45s down to ~0.95s (~2.5x faster) but extended the slow tail to 5.21s. Implication for perf reviewers: a single-sample regression on a BIMODAL_BENCHMARKS entry is NOT actionable — the bench can sample either mode independently of the code change. Always check BIMODAL_BENCHMARKS in tests/benchmarks/run_regression.py before reading delta numbers. The 3 currently-listed bimodal benches are securityfest_fairlight, ekopartyctf2016_sokohashv2, google2016_unbreakable_1.
invariant-perf-warmup-split-gate
remembered
PerformanceTracker (angr/exploration/rust_perf_tracker.py) warmup-split gate: first_* SimProcedure phase twins must be gated on the private _phase_crossing_count (bumped once per real crossing in add_simprocedure_phase when phase=='state_create'), NOT on callback_simprocedure_count. The fast paths in rust_callback_dispatch.py (internal passthrough L667, stale-hook skip L681, deadend fast path L697) and the VEX-fallback increment_simprocedure_count all bump the raw count without recording any phase; gating on it let a fast-path bounce close the warmup window early. Fixed in angr-zi35f.3; covered by tests/engines/rust/test_perf_tracker_warmup_split.py.
invariant-perm-check-3page-test-pattern
forgotten
32-byte loads/stores can span at most 2 pages (PAGE_SIZE=4096), never 3. To exercise the multi-page check_perms_range loop with a true 3-page span, the access width must exceed 4096 bytes — e.g. RustBV::concrete(value, 8208 * 8) at 0x1FF0 ends at 0x3FFF and visits pages 0x1, 0x2, 0x3. RustBV::concrete accepts any width even though its concrete value field is only u128 (high bits zero); the permission check fires before any bytes are written, so the actual data doesn't matter. Used this pattern in test_permission_enforcement_wide_store_three_pages_middle_readonly (memory.rs).
invariant-pin-many-needs-joint-witness
remembered
try_concretize_triop_rm (vex/transcendentals.rs) must concretize BOTH operands from ONE joint witness — ctx.eval_many(&[a,b]), never two ctx.eval calls. Root cause (angr-z8elx, fixed eb2e508ed): SymContext::eval (symbolic/solving_ops.rs) short-circuits in strict-deterministic mode to self.min(bv,false) and deliberately skips the model cache, so back-to-back evals of two operands return independently-minimized witnesses that need not be jointly consistent. When path constraints correlate the operands (a+b==K), each min is individually feasible but the pair is not; the two assume_true pins then make a SAT context UNSAT and the feasible path is silently dropped (missed state, not a crash). GENERAL RULE for any input-concretization site that pins >1 symbolic value: use eval_many (lex_min_witness under deterministic mode, eval_all_in_model otherwise), because pinning is only sound against a model-consistent tuple. The sibling try_concretize_binop_rm has one operand and is exempt. Regression: transcendentals_tests.rs::concretize_triop_rm_pins_a_joint_witness_under_deterministic_mode (a+b==5, Iop_Yl2xF64, asserts still SAT) — confirmed failing pre-fix.
invariant-positioned-io-syscalls
remembered
Native pread64/pwrite64 (syscalls/fd_io.rs NativePread64Syscall/NativePwrite64Syscall) need offset-honoring FileSystem I/O, NOT the append-only FileSystem::write. Added FileSystem::read_at(fd,offset,count) and write_at(fd,offset,data) in state.rs: read_at reads content[offset..] without advancing position; write_at overwrites content[offset..offset+len] and zero-fills the gap when offset>=len. Both leave fd position untouched (POSIX positioned-I/O). pwrite64 gathers ALL concrete bytes before write_at (same no-partial-mutation discipline as writev); symbolic offset/data or non-native/stdin fds fall back to Python. Mirrors posix/pread64.py + pwrite64.py.
invariant-posix-aligned-allocator-semantics
forgotten
POSIX/glibc semantics for the aligned-allocator family (encoded in native/angr/src/procedures/malloc.rs::NativeMemalign and NativePosixMemalign): (1) memalign(alignment, size) — alignment must be a power of 2; glibc does not require it to be a multiple of sizeof(void*) historically, so we only reject non-power-of-2. (2) posix_memalign(memptr, alignment, size) — alignment MUST be a power of 2 AND a multiple of sizeof(void*). On violation, returns EINVAL (22) WITHOUT writing *memptr and WITHOUT setting global errno. On success, returns 0 and stores the aligned ptr at *memptr. The return value is the errno code itself (int return), not the global errno. Both share a new state.rs helper heap_alloc_aligned(size, alignment) that bumps heap_brk up to alignment first, then bumps by the usual 16-aligned size so subsequent malloc()s stay aligned. The 1MB cap on size matches the existing calloc/realloc bound.
invariant-posix-brk-drift-mirrors-mmap-base
forgotten
posix_brk has the SAME drift risk as mmap_base — Rust bumps state.posix_brk on the brk syscall (syscalls/brk.rs:105) but rust_state_export does NOT push it back to Python's state.posix.brk on stash export. Same fix pattern applies: add get_state_posix_brk getter and _sync_rust_posix_brk_to_state alongside the mmap_base sync at all three sites in _get_stash_states. See angr-0cnm for the mmap_base implementation.
invariant-posix-brk-vs-heap-brk
forgotten
RustSimState.posix_brk field (default 0x1B00000) tracks state.posix.brk for the brk(2) syscall, separately from heap_brk (0xC0000000 default, malloc bump allocator). The two MUST stay distinct: heap_brk is for SimHeapBrk-style malloc, posix_brk is for set_brk-style brk(2). Both carry through all fork/merge/clone paths. Accessors: state.posix_brk()/set_posix_brk(addr). See native/angr/src/state.rs:618-620, native/angr/src/syscalls/brk.rs.
invariant-prefer-z3-native-bv-ops
remembered
When emitting RustBV operators to Z3 (native/angr/src/symbolic/value_z3.rs::to_z3_ast_cached), prefer z3-rs native methods over hand-rolled concat/extract sequences. Rationale: Z3's bv_rewriter has dedicated rules for SignExt/ZeroExt/Repeat/RotL/RotR that fold and propagate efficiently; a hand-rolled equivalent emits more AST nodes that the rewriter must collapse at constraint-assertion time (and may fail to collapse if interleaved with extract/concat already in the input). Concrete examples: ZeroExt already uses inner.zero_ext(bits); SignExt should use inner.sign_ext(bits) (angr-rbnk fix). Pattern to audit next: any other BVOp that materializes via Concat/Extract loops. Search: 'concat|extract' inside the BVOp::* arms in value_z3.rs to_z3_ast_cached match.
invariant-prefetch-cache-on-symbolic-store
forgotten
INVARIANT: any symbolic-address Store path that writes to memory MUST invalidate load_prefetch_cache. Single concretization → load_prefetch_cache.remove(&(addr_concrete, data_size)). Multi/Strided/TooLarge/Failed → load_prefetch_cache.clear() since touched addresses are unknown. The centralized helpers fallback_to_python_store (statements.rs:1058) and update_prefetch_on_store (statements.rs:973) already follow this pattern — but two paths in statements.rs invoke callbacks directly without going through them: (a) StoreG with symbolic guard + symbolic addr at lines 339-379, (b) Store with concrete guard + symbolic addr + symbolic data at lines 410-454. These were missing invalidation; fixed in commit cbc6c3e92 (angr-8s4b). When adding any new symbolic-store path, route through fallback_to_python_store/update_prefetch_on_store, OR add the invalidation explicitly. The hazard window: load_prefetch_cache is populated at block start and cleared on block exit, so the bug only fires when a load+symbolic-store+load all live in the same block at the same concrete address. Rare but real correctness issue.
invariant-proc-address-arith-wrapping
remembered
Address arithmetic in native/angr/src/procedures/ must use wrapping_* -- [profile.release] sets no overflow-checks, so a plain src + size panics in debug and SILENTLY WRAPS in release (the shipped .so), turning a range test into a wrong answer rather than a crash. Concrete case fixed 2026-08-02 (angr-9ke6b.110): NativeMemmove's overlap check was dst > src && dst < src + size, the one non-wrapping site in memcpy.rs; near u64::MAX it misclassified an overlapping move as non-overlapping and copied forward, clobbering unread source bytes. Wrap-safe spelling of 'dst is strictly inside [src, src+size)' is let d = dst.wrapping_sub(src); d != 0 && d < size -- see fn memmove_copies_backward. Grep new procs for bare + / - on address or address+length operands.
invariant-profile-rust-bench-list-vs-criterion
forgotten
criterion --filter does regex/substring match on the full bench id 'group/fn' (or just 'fn' for ungrouped benches). After angr-k5mj (commit ee08a03d4), the criterion ids in native/angr/benches/vex_engine.rs all match the names advertised by tests/benchmarks/profile_rust_bench.sh --list (rustbv_concrete, rustbv_symbolic, rustbv_z3, symcontext_fork, symcontext_fork_scaling, symcontext_check_branch, symcontext_assume, symcontext_push_pop, memory_concrete, memory_symbolic_load, memory_fork, state_fork). Disambiguation gotcha: --filter symcontext_fork still substring-matches both symcontext_fork/2_constraints AND symcontext_fork_scaling/* — anchor with --filter '^symcontext_fork$' or use --filter symcontext_fork/ if you want only the simple fork bench. The same applies to memory_concrete vs memory_concrete_load_8bytes patterns and any group whose name is a prefix of another group.
invariant-profile-rust-bench-script
forgotten
profile_rust_bench.sh (tests/benchmarks/) — wires perf/cargo-flamegraph/callgrind to the criterion vex_engine benches. Two non-obvious traps it now handles:
- Workspace [profile.release] sets strip='symbols'; bench profile inherits it. Without --config 'profile.bench.strip=false' --config 'profile.bench.debug=true', perf reports show only raw addresses (0xNNN). Script passes both.
- z3-sys build.rs reads Z3_SYS_Z3_HEADER but the .venv z3 package may lack include/z3.h. Script auto-falls-back to /usr/include/z3.h when present. Runtime requirements: perf needs perf_event_paranoid<=2 (set via 'sudo sysctl -w kernel.perf_event_paranoid=2' on the loop-agent host, which has passwordless sudo). cargo-flamegraph and inferno-* are NOT installed locally; perf path produces perf.txt (top symbols), perf.script (raw stacks). Convert to SVG offline.
invariant-profiling-collector-direct-fields
forgotten
ProfilingCollector at native/angr/src/exploration/profiling.rs uses pub(crate) direct fields, NOT helper methods. Callsites read/write self.profiling.profiling_enabled, self.profiling.accumulated_stats., self.profiling.native_proc_stats. directly. This was deliberate to keep the refactor mechanical and zero-behavior-change. Adding methods later is fine, but do not 'fix' the access pattern as a separate cleanup — the parent task angr-4j5u was deferred multiple times for cosmetic gain. Field-by-field access is the load-bearing simplicity.
invariant-progress-deadended-needs-predicate-path
remembered
Progress-callback deadended_count is only observable via the PREDICATE explore path (mgr.explore(find=lambda s: False)), which sets drop_terminal_states(False) and keeps terminal states. The plain mgr.run(n=...) and address-find paths call set_drop_terminal_states(True), so deadended states are dropped and deadended_count stays 0 — even with a progress callback. Tests asserting nonzero deadended/avoid/errored counts must use the predicate path. See test_progress_callback_deadended_count and rust_manager.py _explore_with_predicates.
invariant-proxy-gate-callback-only-stores-drop
remembered
flareon2015_5 gate-on root cause (angr-5rjbq): under the callback-memory-proxy gate, the memory_store / memory_store_symbolic_value Python callbacks are DELIBERATE NO-OPS (state.memory is Rust memory; re-entering run() through the proxy double-borrows). The VEX interpreter's handle_symbolic_store Single branch and handle_concrete_store's use_sym_store branch dispatched stores ONLY to those callbacks, so under the gate the value was dropped outright and read back as structurally concrete <BV8 0>. Gate-off worked only because Python's real memory absorbed it. Fix: buffer_store_for_rust_memory (interpreter/statements_store.rs) buffers such stores into pending_(symbolic_)stores so they flush into rust_memory. INVARIANT: any store the interpreter routes exclusively through a Python memory callback must also land in rust_memory when the proxy gate is on.
invariant-proxy-python-mgr-vs-mgr
remembered
RustStateProxy sub-proxy ctors (RustMemoryProxy, RustRegisterProxy, RustSolverProxyPlugin in angr/exploration/rust_state_proxy.py) take an optional python_mgr=None kwarg used ONLY for counter bumps (stats_proxy*). The first positional arg ('_mgr') is the PyO3 builtin RustExplorationManager which has NO Python attributes — any new instrumentation that touches Python-side counters MUST go through python_mgr, not _mgr. Construction sites that pass it today: RustStateProxy.regs/memory properties (lines 2813/2827), the proxy gate installers in rust_callback_dispatch.py (lines 2265/2283/2297), and the .copy() methods (lines 352/1085/1162). Adding new construction sites? Pass python_mgr through if you want counter visibility; safe to omit for stand-alone low-level unit tests.
invariant-proxy-readonly
forgotten
ARCHITECTURE INVARIANT: RustStateProxy is read-only by design (proxy.regs.=val, proxy.memory.store, proxy.solver.add). solver.add is the ONLY exception — it write-throughs via the _rust_add interceptor (rust_callback_dispatch.py). Register/memory writes via the proxy are NOT silently supported; they raise NotImplementedError with the documented workaround. The workaround is: (a) mutate the seed state before exploration, or (b) use a SimProcedure-style proj.hook(addr, fn) (those get a full SimState and writes sync via diff-and-push at rust_state_sync.py). When extending the proxy, do NOT add silent write fallbacks — keep the loud-error contract from angr-osuu.
invariant-proxy-setattr-underscore-guard
forgotten
When adding setattr to a class with internal underscore-prefixed attributes (e.g. RustRegisterProxy._mgr/_state_id/_cache/_arch), always guard the underscore branch via object.setattr in the setattr override. Without this, the constructor's first self._mgr = ... assignment would recurse back through setattr before _mgr exists, causing infinite recursion or AttributeError. Pattern: if name.startswith('_'): object.__setattr__(self, name, value); return. Also use object.setattr explicitly in init for clarity and to document intent. Why: angr-j28e RustRegisterProxy added setattr to route public-name writes through Rust FFI while keeping internal state Python-side. How to apply: any future write-through proxy with internal Python state needs the underscore guard.
invariant-proxy-step-state-contract
remembered
RustSimulationManagerProxy.step_state() (rust_state_proxy.py, E1.b/angr-op0dn.1.2) diverges from SimulationManager.step_state() in two ways that callers must respect. (1) It CONSUMES the stepped state: the Rust pymethod uses StashManager::take_state, so the source state leaves its stash during the step — unlike the Python engine, where step() drops it only after bucketing. (2) Successors are returned as exported SimStates but their Rust twins MUST stay in the _step_out quarantine stash: _snapshot_to_angr attaches a Rust-solver eval fallback keyed on the rust state_id, so draining _step_out would break eval on the returned states. The caller places them with move_state(sid, '_step_out', 'active'). successor_func is unsupported by design (raises NotImplementedError) — the engine never builds a SimSuccessors; techniques needing it must run use_rust_engine=False. successors() likewise still raises.
invariant-proxy-unmapped-access-semantics
remembered
callback-memory-proxy UNMAPPED-ACCESS semantics, both halves (angr-s0x0v commit 2f807a8c7, supersedes the store-only invariant-proxy-unmapped-write-semantics from angr-ijwp0 commit 2815611e4). Under ANGR_RUST_USE_CALLBACK_MEMORY_PROXY the Rust state IS the memory a SimProcedure sees, so RustMemoryProxy must reproduce angr DefaultMemory semantics ITSELF — 'fall back to Python' is not available. Rule for BOTH load and store: a page neither Rust nor the pre-swap _fallback_memory has mapped => under STRICT_PAGE_ACCESS raise SimSegfaultException(page_base, 'unmapped') via RustMemoryProxy._segfault_unmapped (page base = addr - addr%0x1000, reason 'unmapped' — this is exactly what angr PrivilegedPagingMixin._initialize_page raises; do NOT invent reasons like 'write-miss'); otherwise map on demand (store: *_automap setters; load: RustMemoryProxy._unmapped_read returns a zero BVV). The old load behavior (silently return BVV(0)) is a SILENT divergence: a libc proc dereferencing a garbage pointer reads zeros and sails on where Python segfaults. Any new proxy memory path must route its miss through _segfault_unmapped.
invariant-pufm-original-gaps-closed
forgotten
The three originally-described angr-pufm hard-error sites are CLOSED (do NOT reopen as gaps): expressions.rs:138-154 Load TooLarge/Failed → fallback_load_symbolic_full; statements.rs:367-377 StoreG sym-guard non-Single → has_memory_store_symbolic_full guard; statements.rs:1101-1135 Store TooLarge/Failed → fallback_store_symbolic_full; statements.rs:641-656 CAS sym-addr sym-data → has_memory_store_symbolic_full guard. Closure path: angr-b1qq wired the symbolic-full callbacks; angr-8mh1 (commit 0c90d4962) extended fallback to LoadG/Load-Failed/Store-Failed. The 'resolve natively for 1000+ solutions' acceptance criterion is multi-session lazy-memory work, NOT a pufm gap — tracked in angr-czph (loads) + angr-qh5u (stores) + angr-pogf (design research).
invariant-pyapi-vs-mod-rs
forgotten
pyapi.rs is NOT included via include!() in mod.rs. Both files define methods on RustExplorationManager, but ONLY mod.rs methods are compiled into the #[pymethods] block. pyapi.rs appears to be an unused/stale copy. When adding new Python-exposed methods, add them to mod.rs.
invariant-pycallbacks-mutable-fields
remembered
INVARIANT: any new mutable field added to PythonCallbacks (struct defined in native/angr/src/callbacks/mod.rs — dir module since angr-zel8z.2) that needs to be updated AFTER mgr.set_callbacks(callbacks) runs must be wrapped in a shared container (Arc or Arc<Mutex>). The struct derives Clone and the Rust manager keeps a clone. Primitive fields silently desync across the two copies. See inspect-enabled-clone-pitfall for the discovery. Callback Py fields are safe because Clone bumps the refcount on the same Python object; primitives are bitwise-copied and independent.
invariant-pymethods-raw-ptr-validate
remembered
Any #[pymethods] entry that takes a raw Z3/pointer usize from Python must validate it before deref: use std::ptr::NonNull::new(...).ok_or_else(|| PyValueError...) NOT NonNull::new_unchecked. set_register_symbolic (native/angr/src/state/pymethods.rs) was fixed in angr-ph300.49 — a Python-passed 0 (the guarded absent-AST value in rust_state_sync.py) was UB + Z3_inc_ref segfault. AUDIT SIBLING: engine.rs (~line 129) still does NonNull::new_unchecked(py_z3_ctx_ptr as *mut _) on a Python-provided ctx ptr — same class of bug if reachable with 0/garbage; not yet fixed.
invariant-pyo3-getter-strips-get
forgotten
PyO3 #[getter] strips 'get_' prefix from method names when exposing to Python. E.g., 'pub fn get_is_concrete(&self) -> bool' is accessed as 'handle.is_concrete' (not 'get_is_concrete'). Bit me when writing PyO3 round-trip tests; AttributeError nudged toward 'is_concrete'. Test patterns: handle.id, handle.width, handle.length (all getters), handle.concrete() (regular method).
invariant-pyo3-mgr-mock
forgotten
PyO3-bound Rust manager methods are read-only attributes — 'mgr._rust_mgr.method = recorder' raises AttributeError. To unit-test code that calls _rust_mgr methods, replace the whole handle: 'mgr._rust_mgr = SimpleNamespace(method1=fn1, method2=fn2, ...)' covering only the methods exercised by the code path under test. Restore the original handle in a finally block. See test_vex_fallback_forks_multi_successors in tests/engines/test_rust_exploration.py for the pattern.
invariant-pyo3-needless-pass-by-value
remembered
clippy::needless_pass_by_value on a #[pymethods] fn arg (e.g. segmentlist.rs next_pos_with_sort_not_in's sorts: HashSet<Option>) is a FALSE POSITIVE: PyO3 extracts args by value via FromPyObject; &HashSet does not implement the needed extraction (only &str/&[u8]/&Bound/&PyClass do), so the clippy fix breaks compilation. Confirm a fn is internal-only (grep Rust callers) before applying needless_pass_by_value. The 167yo audit bead mis-cited this as actionable. needless_pass_by_value is pedantic, not in the default -D warnings gate, so it never reddens CI anyway.
invariant-pyo3-no-new-raw-ptr-surface
forgotten
INVARIANT: do NOT add new unchecked Python-exposed FFI surfaces that take a raw integer and dereference it as a Z3 pointer. The main import path _import_z3_constraint_ptrs (native/angr/src/exploration/state_api.rs, ~:100) is now VALIDATED (angr-33t9): before handing ptrs to add_constraint_raw's unsafe wrap it rejects (a) null via NonNull::new -> PyValueError, (b) non-AST / foreign-context ptrs via Z3_get_sort returning None, and (c) non-Bool sorts via Z3_get_sort_kind. This catches the cheap-to-detect misuse (null mid-list, BV exported by mistake, foreign-context AST). RESIDUAL UB: truly garbage integers (e.g. 0xdeadbeef) still get dereferenced inside Z3_get_sort and may segfault before Z3 can signal — the add_constraint_raw SAFETY contract is unchanged. Two OTHER raw-ptr surfaces remain unchecked: engine.rs:131 and state.rs:2900 (both std::ptr::NonNull::new_unchecked). When adding new #[pymethods] on RustExplorationManager/RustSolverContext/PyRustSimState that take usize/u64 pointer-shaped values, validate at the FFI boundary (the state_api.rs probe is the template) or wrap in an opaque newtype. Documented in docs/advanced-topics/rust_engine.rst 'Raw Z3 pointer fast path'.
invariant-pyo3-single-pymethods-impl
remembered
PyO3 0.27.2 in this project does NOT enable the 'multiple-pymethods' feature (Cargo.toml: features=["py-clone","abi3-py310"]). Consequence: each pyclass may have exactly ONE #[pymethods] impl block. Two valid decomposition moves given that constraint: (A) SPLIT the wrappers across files -> would need >1 block -> feature unavailable -> instead keep thin #[pyo3(signature=...)] wrapper in mod.rs and move the BODY to a pub(crate) fn in a sibling plain-impl module (pattern: exploration/run_loop.rs, pending_api.rs, stepping.rs, resume.rs, helpers.rs). (B) MOVE the entire single block verbatim to one sibling file -> still exactly one block -> works with NO feature. The #[pymethods] block does NOT have to live in the same file as its #[pyclass]; it only must be the sole block and compiled in-crate. Demonstrated angr-0mqkc.5 inc9 (7ac9c0fe2): state/pymethods.rs holds the whole #[pymethods] impl PyRustSimState (~749 lines of thin wrappers), #[pyclass] struct + inner()/inner_mut() plain impl stay in state/mod.rs. Sibling pymethods.rs uses 'use super::*;' (which DOES surface mod.rs's private 'use' imports like RegisterFile/HashMap/AddressConcretizer via glob) plus a few explicit imports (Permission, RustBV, PyValueError, PyAny/PyDict, Arc). Use (B) when moving ALL pymethods of a type; use (A) when splitting them into groups. Adding multiple-pymethods would pull in the 'inventory' crate + change class-registration semantics -- out of scope.
invariant-pytest-memory-budget
forgotten
Full pytest run on test_rust_exploration.py (207 tests) uses ~5GB RSS toward the end and risks OOM on 8GB no-swap machine. Pytest accumulates state across all 207 angr explorations in one process. Workarounds: (1) run a subset with -k filter (e.g. 'fauxware or solver or constraint or memory or basic or callback' = 41 tests, ~2s, ~1GB), (2) use --forked with pytest-forked to fork per test (slower but bounded), (3) split with pytest -p no:cacheprovider --tb=no --co -q to list tests then chunk.
invariant-python-procedure-num-args
remembered
register_python_procedure (native/angr/src/procedures/python_proc.rs): num_args mismatch is silent — the dispatcher's extract_procedure_args extracts EXACTLY the registered count from arg regs/stack; caller's actual ABI count is never checked. So a callable registered with num_args=2 just won't see a third argument that's sitting in RDX. There is no crash, no warning, no truncation error. If you want to validate caller-vs-callee count, add a separate mechanism.
invariant-python-procedure-return-extract
remembered
register_python_procedure return-value handling: callable returning -1, a value > u64::MAX, a non-int, or a float all fall back to Python via ProcedureError::Other (extract::() fails). dispatcher counts these as native_proc_stats.python_fallbacks (NOT native_calls), then emits need_simprocedure. So 'callable raised an error' and 'callable returned an invalid value' are indistinguishable from the dispatcher's perspective — both look like a Python fallback to the observer. RAX is left untouched on failure, which is the safe behavior.
invariant-pyvex-bridge-imports
forgotten
pyvex_bridge.rs tests need IROp, JumpKind, Endness imports added to the top-level use statement. These were missing and blocking all test compilation. Fixed in commit 0c7e0c695.
invariant-pyvex-const-serialize-shape
remembered
rust_irsb_serializer.py serialize_const must special-case any pyvex const whose Rust PyVexConst variant uses named fields other than 'value'. U128 and V128 both deserialize into {low, high} structs (native/angr/src/vex/pyvex_bridge.rs PyVexConst::U128/V128); V256 uses {value: [u64;4]}. The generic '{tag: Ico, value}' fallback silently mismatches these serde shapes. Fixed U128 in angr-1yge9.6.
invariant-qdmull-vex-op
remembered
Iop_QDMull16Sx4/32Sx2 (signed doubling saturating widening multiply, NEON VQDMULL, (I64,I64)->V128) map to IROp::VQDMull{elem,count} in vex/opcode_map.rs. Implemented by vec_qdmull in ops_vec_permute_mul.rs: same full-lane widening layout as vec_mull (even=false, always signed), plus 2 and signed saturation to 2N bits. Saturation only fires on MINMIN (both -2^(N-1)): doubled product 2^(2N-1) clamps to 2^(2N-1)-1. Symbolic path widens inputs to out_width+2 bits before doubling to avoid overflow, then reuses saturate_lane_symbolic (widened to pub(super) in ops_vec_saturate.rs). Only 16Sx4/32Sx2 exist in libVEX; the QDMulHi/QRDMulHi high-half family is still unmapped.
invariant-rangemap-inverted-range-aborts
remembered
rangemap + panic=abort makes an inverted Range a PROCESS ABORT, not a Rust panic Python can catch. native/angr/Cargo.toml sets [profile.release] panic="abort", and rangemap::RangeMap::{insert,remove} both assert!(range.start < range.end). So any 'address..address + size' built from Python-supplied values without a checked_add guard is a DoS vector, not a wrong-answer bug. Fixed in SegmentList::release (native/angr/src/segmentlist.rs) for angr-9ke6b.199; SegmentList::occupy already had the guard. Convention adopted at both sites: 'let Some(end) = address.checked_add(size) else { return; }' tagged // SILENT(cat-a) -- a wrapping range describes no real region, so a no-op is the correct answer, not a degraded one. If a new SegmentList-like RangeMap wrapper is added, grep for unchecked '+ size' in range construction first.
invariant-rdtsc-per-state-tsc
remembered
RDTSC determinism (angr-9ke6b.173): the simulated timestamp counter is per-RustSimState (field tsc_counter, accessors tsc_counter()/set_tsc_counter() in state/process.rs), NOT a process-wide static. It is carried into the interpreter as VEXInterpreter::dirty_helper_state (DirtyHelperState{tsc} in vex/dirty.rs) by run_interpreter_step_core and written back by apply_interpreter_step_result. INVARIANT: any future stateful dirty helper must put its mutable scratch in DirtyHelperState and add the same seed/write-back pair, or it re-introduces the cross-state/cross-run nondeterminism. Simulated time merges as a MAX watermark in RustSimState::merge (same class as heap_brk/mmap_base) so it never runs backwards, and the per-RDTSC bump saturates rather than wrapping.
invariant-readv-is-symbolic-fast-path
remembered
readv (NativeReadvSyscall, syscalls/fd_io.rs) has an is_symbolic fast path (angr-myzjx.12): when a fd is open with no concrete content (content_len==0) AND FileSystem::is_symbolic(fd) is true (the stdin model via open_symbolic), it scatters fresh symbolic bytes natively per segment via the shared scatter_symbolic helper — mirroring read.rs's read_symbolic path — instead of falling back to Python. The stdin path (fd==0) uses the same helper. pread64 DELIBERATELY lacks this fast path: a positioned read on a stream model has no clear position semantics, so it keeps deferring to Python. Any new native read-family syscall serving symbolic-stream fds should reuse scatter_symbolic for name/counter consistency.
invariant-recip-rsqrt-fresh-symbolic
remembered
Iop_RecipEst/Step and Iop_RSqrtEst/Step (FP variants) are emulated as fresh-symbolic per lane — angr Python has _op_fgeneric_RSqrtEst returning BVS and no handler for the others, so matching that policy avoids divergence. Newton-Raphson refinement loops in binaries converge to the exact 1/x or 1/sqrt(x) regardless of seed, so the precision loss is recoverable. Computing 2.0-xy / (3.0-xy*y)/2 symbolically would be MORE faithful than angr Python and could cause cross-engine divergence.
invariant-record-stdin-symbol-required
forgotten
Native SimProcedures that create symbolic stdin bytes MUST call state.record_stdin_symbol(name, bits) for each byte, or the Python state export path will not be able to inject the bytes back into posix.stdin.content — meaning posix.dumps(0) returns b'' for states forked purely on the Rust side. This is why angr-q7ij happened: NativeRead created stdin_{read_id}_{i} bytes but never recorded them. _inject_rust_stdin (angr/exploration/rust_callback_dispatch.py:327) short-circuits on !has_state_stdin_symbols, so the symbolic bytes were invisible to Python's posix.dumps. Fixed by commit 4e9032f85. fgets/fgetc/getchar/scanf already do this correctly — use them as the pattern.
invariant-register-dirty-bitset-incomplete
forgotten
RustSimState's old dirty_registers u128 bitset (removed in angr-xvnz) only addressed the first 512 bytes of the register file (128 bits × 4 bytes/bit). amd64 register file is ~1664 bytes, so XMM/YMM/AVX writes (offset > 512) silently fell out of the bitset because of 'if bit < 128' guards in set_register/set_register_by_offset. If anyone reintroduces register-dirty tracking, use a Vec sized to arch.state_size()/4 or a HashSet — not a u128.
invariant-register-file-concrete-buffer
remembered
RegisterFile::copy_to_bytes (native/angr/src/arch/mod.rs) zeroes the byte span of every entry in self.symbolic after copying self.data, because RegisterFile::put's symbolic branches never clear the concrete buffer: a register written concretely then overwritten symbolically leaves stale concrete bytes in data. INVARIANT for future edits: the flat register buffer is a CONCRETE-ONLY view — a byte that belongs to a symbolic register reads 0, and the symbolic value travels separately via ExplorationStateSnapshot::get_symbolic_register_names. Corollary for the wider audit family: self.data and self.symbolic are NOT kept mutually exclusive (put's sub-register compose branches deliberately write both), so any new reader of self.data must consult self.symbolic itself — RegisterFile::get and get_offset_u64 do; get_sp_value deliberately does not (documented fast concrete path). Also audited: get_registers_raw has NO Python caller — rust_state_export.py rebuilds registers from get_registers_named + get_symbolic_register_names.
invariant-register-import-via-claripy-to-rustbv
remembered
Subregister symbol identity (angr-21vi5/4ju9e Layer 2): when importing a symbolic register from Python into Rust, route the FULL claripy AST through claripy_to_rustbv, NOT the raw-Z3-ptr path (PyRustSimState::set_register_symbolic). set_register_symbolic wraps the whole register value as an opaque RustBV::Symbolic{id:0}, so rustbv_to_claripy_memo (CLARIPY_AST_CACHE miss on id 0) mints a fresh BVS on export — losing any embedded leaf symbol (e.g. state.regs.ecx=BVS('ecx') -> rcx becomes opaque, exports as rcx_N not ecx). FIX: PyRustSimState::set_register_symbolic_ast(name, ast) calls claripy_to_rustbv which interns every leaf BVS into the shared cache (store_claripy_ast_with_info), so it round-trips. rust_state_sync.py register import prefers it (hasattr guard for old .so). Mirrors the memory-import path (_import_symbolic_to_state). Identity preservation depends on a NON-zero RustBV::Symbolic.id keyed into CLARIPY_AST_CACHE.
invariant-register-names-u128-width
remembered
INVARIANT: Arch::register_names() (the per-arch REGISTER_NAMES const in arch/amd64.rs, x86.rs, arm.rs, ...) is the set of registers that cross the Python boundary — it drives register_names_for_arch (Python _supported_register_names in rust_state_sync.py) and RustSimState::export_full's named_registers. It is a strict SUBSET of that arch's CANONICAL table, and every entry MUST be <= 16 bytes: ExplorationStateSnapshot::get_registers_named carries each value as a u128, and RegisterFile::get's concrete read composes bytes with 'value |= (byte as u128) << (i8)', so a wider register silently yields garbage (release-mode shift masking) instead of failing. This is why x86/AMD64 'fpreg' (64 B, 8 x87 slots) is deliberately absent while fptag/fpround/fc3210/ftop were added in angr-9ke6b.6 (commit d24970d34). Gated by arch::tests::register_names_all_resolve_and_fit_in_u128 in arch/mod_tests.rs. Corollary for future audits: a CANONICAL entry missing from REGISTER_NAMES is a silent state.regs. omission bug UNLESS it is >16 bytes.
invariant-register-proxy-cache-invalidation
remembered
RustRegisterProxy._cache (per-name read cache, rust_state_proxy.py ~line 808) is NEVER invalidated when the underlying Rust state steps. Any code that holds a long-lived RustRegisterProxy across Rust steps (e.g. a state cached in _state_cache with a proxy as its registers plugin) will read STALE register values. The X_proxies() stash accessors mint FRESH proxies (empty cache) so they read live. INVARIANT: when re-using/materializing a state whose registers plugin is a RustRegisterProxy after Rust has stepped, clear proxy._cache (and rebind _state_id) or reads silently return pre-step values. RustRegisterProxy.copy() returns a fresh proxy (empty cache, but preserves the SOURCE _state_id). See register-proxy-stale-cache-root-cause and invariant-set-register-ip-syncs-pc.
invariant-register-syscalls-macro
forgotten
register_syscalls! macro at native/angr/src/syscalls/mod.rs takes (num, handler_expr) tuples per-arch (register_syscalls!(r, "ARM64", [(63, read::NativeReadSyscall), ...])). Wraps each in Arc::new(...) and inserts via r.register(). Linux syscall numbers diverge per ABI (e.g. ARM64 mmap=222, no native x86/ARM/MIPS32 mmap), so the macro keeps separate per-arch tables; it just removes the r.register(\"ARCH\", N, Arc::new(...)) boilerplate. To add a new syscall handler: append one row per supported arch table inside NativeSyscallRegistry::new().
invariant-registerfile-arc-cow-makemut
remembered
RegisterFile (native/angr/src/arch/mod.rs) stores registers as data: Arc<Vec> (NOT Vec) since angr-6t8z3.1. fork()/derived Clone share the buffer O(1) via Arc::clone; the deep copy happens lazily on first write. EVERY &mut self register write MUST route through Arc::make_mut(&mut self.data) or it will silently leak across the fork boundary (CoW broken). Current write sites: put()'s three concrete-write loops + copy_from_bytes(). set_ip/set_sp delegate to put; merge mutates only self.symbolic. Reads index self.data[..] directly (Deref). Serde shadow RegisterFileData.data stays Vec: From uses Arc::unwrap_or_clone, From wraps Arc::new. Faithfulness gate: test_register_file_fork_cow_isolation in mod_tests.rs. Measured state_fork bench -9.6%. Extends invariant-symbolic-memory-fork-fields CoW discipline to registers.
invariant-remove-technique-teardown
remembered
remove_technique native teardown (angr-w9zce): the Rust manager has NO per-technique unregister. rust_techniques.py::_disarm_native_technique resets whole native categories to default then re-arms the surviving _active_techniques via _arm_native_technique. Categories + their reset entry points: selection -> set_state_selection_fifo; Explorer -> set_find_addrs([])/set_avoid_addrs([])/set_num_find(1); CheckUniqueness -> disable_uniqueness_filter; LoopSeer/LengthLimiter/Timeout/ManualMergepoint all share the native_techniques vec whose only entry point is clear_native_techniques (clear-all), hence clear-then-re-arm. Category membership lives in the frozensets _SELECTION_TECH_NAMES / _FIND_AVOID_TECH_NAMES / _UNIQUENESS_TECH_NAMES / _NATIVE_QUEUE_TECH_NAMES — INVARIANT: any new branch added to the _arm_native_technique if/elif chain must be added to the matching frozenset or its effect survives removal silently. Scoping per category is deliberate: a blanket reset would clobber a manual set_state_selection_random/set_find_addrs made outside the technique API.
invariant-repeat-run-equality-projections
remembered
M2 repeat-run result equality (angr-op0dn.10.4) is measured by tests/benchmarks/repeat_run_equality.py, and the ONLY two projections it compares are (a) stats['found_pcs'] — the found-state pc multiset, opted in per-child via env ANGR_BENCH_FOUND_FINGERPRINT=1 (spawn inherits it) — and (b) the bench's stdout (evaluated model bytes / flag). Do NOT swap in content_fingerprint.fingerprint_terminals: it is vacuous on Rust found states (see avoid-content-fingerprint-on-found-states). The reduction MUST stay hashlib-backed (content_fingerprint::_stable_digest), never builtin hash(), because each repeat is a separate PYTHONHASHSEED-randomized process. equality_verdict([]) returns equal=False on purpose: a bench whose repeats all crashed must fail the gate, not pass it vacuously. The harness carries ZERO wall-clock assertions by design — timing variance is out of M2 scope. Measured 2026-07-13: 20 runs x {fauxware, ais3_crackme, defcamp_r100} with deterministic=True are result-identical on both projections (tests/benchmarks/repeat_equality_numbers.json).
invariant-required-option-inverse-polarity-gate
remembered
EXTENDED_IROP_SUPPORT is an INVERSE-POLARITY option gate for the Rust engine: the divergent configuration is the one where it is UNSET, not set. It only ever widens Python's IR-op table (angr/engines/vex/claripy/irop.py::vexop_to_simop auto-generates a SimIROp from the op name when extended=True, raises UnsupportedIROpError when False). Set — and it ships in EVERY mode bundle, so it always is — Rust honors it transparently: it runs the op natively, or defers the block to Python where the widening applies. Unset, the user is asking for the NARROW table and the Rust interpreter has no narrow mode, so it would execute an op Python would have refused. Hence it lives in _REQUIRED_OPTION_NAMES (refused by absence), not _RAISE_OPTION_NAMES (refused by presence), in angr/exploration/rust_manager.py. rust_unsupported_options() folds both polarities and reports a miss as 'unset EXTENDED_IROP_SUPPORT'. Same trap shape as CGC_NON_BLOCKING_FDS (see invariant-cgc-fdwait-option-polarity). When adding a SimOption gate, ALWAYS ask which polarity diverges — a default-bundle option can only ever diverge in the unset direction. TEST-PROBE COROLLARY (absorbed in the R4 merge): any test probing angr.exploration.rust_unsupported_options() with a synthetic option set MUST include EXTENDED_IROP_SUPPORT -- since angr-op0dn.14.7 its ABSENCE is itself an unsupported condition, so a bare probe like rust_unsupported_options({o.SOME_OPTION}) never returns []: it returns ['unset EXTENDED_IROP_SUPPORT'] and the assertion fails for a reason unrelated to the option under test (every real state carries it -- it ships in every mode bundle). Pattern: define a _BASE = {'EXTENDED_IROP_SUPPORT'} constant and probe with _BASE | {option}. See tests/engines/rust/test_mode_bundle_options.py.
invariant-reset-for-stage-clears-all-stashes
remembered
invariant-reset-for-stage-clears-all-stashes: RustExplorationManager::reset_for_stage (exploration/state_lifecycle.rs) must (a) sweep EVERY stash name except STASH_ACTIVE — never a hardcoded name list; the old list omitted STASH_PRUNED and all technique stashes (cut/spinning/timeout/not_unique/copies/merge_waiting*), keeping stage-1 states and their Z3 solver clones alive for the whole session — and (b) unindex()+remove_root() each dropped active state; a bare VecDeque::retain() bypasses state_index/state_roots so state_stash(dropped_id) keeps answering 'active' and both maps grow unboundedly across multi-stage explores. Any code that removes a state from a stash by hand must mirror drop_state_from_stash (manager_methods.rs): take_state_from + remove_root. Covered by reset_for_stage* tests in exploration/state_lifecycle_tests.rs (angr-ph300.26).
invariant-residual-sink-vs-assumed-ir
remembered
Constraints imported from Python via the Z3-ptr fast path (exploration::constraints::import_python_constraints, the shared body behind BOTH _add_constraints_to_state and _add_constraints_to_pending since angr-9ke6b.71) used to be recorded TWICE: add_constraint_raw logged them in the residual sink (SymContext::local_constraints.non_bv_assertions) AND the call site pushed an assumed IR pair. Invariant: the residual sink is ONLY for constraints with no RustBV form. Anything reconstructible from the assumed IR must go in via add_constraint_raw_assumed (constraint_ops.rs), which skips the residual push. Violating this silently breaks SymContext::unsat_core_assumed, which asserts residuals UNGUARDED (they have no claripy AST to blame) — a double-listed constraint is then pinned outside its assumption literal and the unsat core comes back EMPTY. Symptom looked context-dependent (only after a real RustExplorationManager swapped the Rust thread-local Z3 ctx for claripy's) because AST-identity dedup happens to catch the dup in a Rust-owned context but not in claripy's. See [[unsat-core-on-demand-not-tracked]].
invariant-resolve-main-direct-call-fallback
remembered
Rust init-to-main for thin-entry binaries: _resolve_main_address (rust_manager.py) resolves main via (1) 'main' symbol, (2) _start's rdi/edi PUT const (x86-64 __libc_start_main), (3) NEW fallback — entry block's direct Ijk_Call constant target when in main_obj && != entry && not a SimProcedure. The simproc guard is what keeps dynamically/statically-linked __libc_start_main binaries safe: their entry calls __libc_start_main which angr hooks as a SimProcedure, so fallback 3 skips it and fallback 2 (rdi parse) handles them. Only symbol-less direct-'call main' entries (CGC/DECREE) hit fallback 3. Inert for the benchmark corpus (all have main symbols or rdi parse). If you add a binary whose entry directly calls a non-main helper before main, this could mis-resolve — verify with mgr._resolve_main_address().
invariant-restore-plugins-cost
forgotten
_restore_plugins_to_state (angr/exploration/rust_state_export.py:1153) runs for every materialized Rust-owned state and is on the hot path for FAST benches (<0.3s). Any new step added there shows up directly in defcamp_r100 / fauxware / similar 0-callback benches. Audit cost before adding. Note: _install_rust_history_warning landed here in 07630755c with measurable ~10-40ms overhead per state (median 0.23s -> 0.27s on defcamp_r100).
invariant-restore-plugins-template-walk
remembered
invariant-restore-plugins-template-walk: _restore_plugins_to_state's template selection must walk ancestors in proximity order, never picking an arbitrary cached state. Acceptable templates: (1) state_id itself, (2) snapshot.parent_id, (3) tracked root via _state_roots/get_state_root, (4) ANY cached root (not descendant). Falling back to a forked descendant is a correctness bug — descendants carry per-fork plugin mutations (posix.fd, heap allocations, fs entries) that must not leak across paths. Why: angr's plugin model says forks have independent plugin state; collapsing them to a wrong template silently cross-pollinates branches. How to apply: when adding new plugin-restore call sites, always go through _find_plugin_template_state (or pass snapshot_parent_id if you have a snapshot).
invariant-result-type-width-from-opcode-map
remembered
IROp::result_type() (vex/ir/ops_def.rs) must derive packed-vector widths from elemcount via the Self::width_total_to_type helper, never hardcode IRType::V128. The hardcoded arm was wrong in BOTH directions, not just the AVX2 one the audit bead flagged: opcode_map maps VAdd/VSub/VMul/VShlN/VShrN/VSarN/VCmpEQ/VCmpGT at D-reg NEON 64-bit totals (Iop_Add8x8 -> I64) as well as Q-reg/SSE 128 and, for VAdd/VSub only, AVX2 256 (Iop_Add8x32/16x16/32x8/64x4). Whenever an arm of result_type hardcodes a width, cross-check the corresponding parse_opcode arm in vex/opcode_map.rs for the full set of mapped shapes -- the two files are independent sources of truth and drift silently, because result_type is only consulted on the Err() fallback path of interpreter/expressions.rs (sizing a fresh-symbolic value) plus IRExpr::result_type in vex/ir/ast.rs, so a wrong width produces no test failure. The regression test test_packed_lane_result_type_tracks_mapped_width in vex/ir_tests.rs pins this by driving expectations through parse_opcode; extend it rather than writing a fresh literal-IROp test. Widening arms need their own rule: VMull{even=false}/VQDMull double lane width (2elemcount) while VMull{even=true} (Iop_MullEven) halves the lane count as the width doubles (elem*count). Remaining known hardcoded-V128 arms with an explicit 'for now' caveat: VMin/VMax/VAbs/VFAdd/VFSub/VFMul/VFDiv/VFSqrt/VFAbs/VFMin/VFMax.
invariant-resume-extension-impl
forgotten
exploration/resume.rs at native/angr/src/exploration/resume.rs holds the bodies of the pyclass resume_after_simprocedure/_syscall/_hook/_error/_symbolic_branch/_find_predicate/_avoid_predicate/deadend_pending_callback methods as pub(crate) fn resume* / _deadend_pending_callback. The pymethods entries in mod.rs are 1-line forwarders that keep the #[pyo3(signature)] attributes. Edits to resume-callback semantics belong in resume.rs, not mod.rs. Same pattern as run_loop.rs / stepping.rs / helpers.rs. Why: PyO3 0.27.2 here does NOT enable multiple-pymethods (Cargo.toml 'features = ["py-clone"]'), so each pyclass is limited to one #[pymethods] impl block — the extension blocks are plain 'impl RustExplorationManager { ... }'. How to apply: change resume callback logic in resume.rs; only change mod.rs if a Python-facing signature changes. (angr-4j5u.5.2, 2026-05-08)
invariant-resume-fork-base-must-precede-guard
remembered
invariant-resume-fork-base-must-precede-guard (FIXED angr-khpsh, commit on rust-symex 2026-07-22): in exploration/resume.rs, ALL THREE resume entry points now derive the deferred-fork base from a state carrying NEITHER the branch guard NOR the deferred forks' taken-path constraints. Concretely _resume_after_simprocedure and _resume_after_symbolic_branch build fork_base = pending.pre_callback_snapshot.unwrap_or_else(|| state.fork()) BEFORE calling apply_deferred_fork_constraints(&state, ..); _deadend_pending_callback never calls it up front at all and passes the parking state as MaterializeForkCtx::guard_sink. Any future edit that hoists a constraint-application above the fork_base binding silently prunes every snapshot-less deferred fork's unexplored side to STASH_PRUNED (it becomes trivially UNSAT). Regression: resume_tests.rs::snapshotless_resume_keeps_deferred_fork_live_in_every_entry_point (parity across all three entry points, no snapshot). Same bug class as angr-ph300.9 but for taken-path constraints rather than the branch guard.
invariant-ret-empty-callstack-test-fixture
forgotten
When writing a Rust-engine test that steps a state whose execution path terminates with a 'ret' instruction not preceded by a 'call' (e.g. shellcode 'cmpxchg16b [rdi]; ret' starting from blank_state, or a native SimProc dispatch that sets PC to an address whose block eventually rets to a symbolic/zeroed stack value), the state lands in the 'unconstrained' stash per angr-3uye.2 (commit 974e2daeb, native/angr/src/interpreter_cb/exits.rs:184-200). Build the manager with save_unconstrained=True so the post-step state survives — by default _explore_with_addresses clears 'unconstrained' after every batch (angr/exploration/rust_manager.py:2871-2878). When asserting on post-step state, include mgr.unconstrained (or 'unconstrained' in stash iteration) alongside active/deadended/errored. angr-ruay (2026-05-13) fixed 3 tests with this pattern.
invariant-retiring-a-bounce-can-move-it
remembered
Retiring a native-proc Python bounce can MOVE the bounce rather than remove it — the ZeroPy gate is per-BENCH (zero crossings), but the census is per-SITE. Concrete case (angr-gorvf.13, iter145): widening native open to concretize symbolic pathnames (like Python's open, which solver.evals the path and does not constrain it) retired the open crossing on fauxware, but the fd it then creates has no backing content, so NativeRead immediately declined with Other('read from fd=N has no concrete content') — bounce count still 1, gate still fails. LESSON: before claiming a bench flips, re-run it and re-read the fallback counters; a proc that ALLOCATES a resource (fd, FILE*, heap chunk) can hand the next proc an unmodelable one. Cheapest way to get the exact decline: RUST_LOG='rustylib::exploration=debug,off' prints 'Native procedure returned error, falling back to Python: ' (the *_fallbacks_by_name counters only bucket by variant). Follow-up: angr-gorvf.15.
invariant-retiring-a-python-callback
remembered
Retiring a dead PythonCallbacks entry is a FIVE-place edit, not two (angr-9ke6b.218 item 1, commit 2a6643a07). Deleting PythonCallbacks::call_ alone leaves the field alive because callbacks/mod.rs drives fields through the with_callback_fields! macro list — the field must be dropped from that list AND from the struct AND its set_ pymethod AND the Python registration in rust_manager.py::setup_callbacks AND the matching gil_profile::CallbackSite variant (which requires bumping CallbackSite::COUNT and the all() array — COUNT is a hand-maintained const, so a stale one is a compile error in all(), which is the good failure mode). Deleting a CallbackSite variant is safe for the ZeroPy gate: run_zeropy_gate.py spells GIL_CLASSES out explicitly and the per-site gil_work_ns_callback keys are generated from CallbackSite::all(), so a removed always-zero site just stops emitting a key nobody reads. Check for a dispatch_tests.rs 'callback not set' table entry too — it names the method by hand.
invariant-reverse-z3-roundtrip-tests
remembered
Invariant for Reverse/Concat Z3 emission in native/angr/src/symbolic/value_z3.rs: any Reverse-related code change MUST include a Z3 round-trip test that pins the inner BV to a concrete value and asserts ctx.eval returns the byte-reversed value. Cargo-only structural checks (e.g. 'parts assemble in this order') won't catch the bug class because Z3 silently folds mis-shaped concats back to x via its bv_rewriter. Pattern: test_reverse_z3_emission_{16,32,64}bit_leaf and test_reverse_z3_emission_concat_of_bytes added 2026-05-19 in value_tests.rs.
invariant-rst-heading-underline-em-dash
forgotten
When writing reST headings that contain em-dash (—) or other multi-byte unicode characters, the underline must match the VISIBLE CHARACTER count (1 per em-dash), not the byte count (3 bytes for em-dash in UTF-8). Validate by running .venv/bin/python3 with len(line) — Python's len() on a string counts visible chars correctly. Caught in angr-0hdq.1 (commit 14709cea8) when adding 'Shared-lineage Z3 solver — rejected' (35 visible chars, 35-dash underline).
invariant-run-loop-extension-impl
forgotten
exploration/run_loop.rs at native/angr/src/exploration/run_loop.rs holds the body of the pyclass run() method as pub(crate) fn run_loop. The pyclass-facing 'pub fn run' in exploration/mod.rs is a 1-line forwarding wrapper. When changing run-loop semantics (state selection, find/avoid, SimProcedure dispatch, deferred-fork handling), edit run_loop.rs — the wrapper in mod.rs is just for the #[pyo3(signature)] attribute. STEPPING_STATE_ID is a private thread_local in mod.rs that's accessible from run_loop.rs because in Rust private items are visible to descendant modules. Same applies to PendingCallback (pub(crate)).
invariant-run-single-partial-stats-on-failure
remembered
tests/benchmarks/run_single.py::_run_in_child surfaces partial Rust diagnostics on failure. The _collect_rust_diagnostics() helper is called from MemoryError handler, generic Exception handler, and the success path; ok=False returns now include 'stats', 'perf_report', 'peak_memory_mb'. Main run_example() also calls _dump_counters_json / _dump_counters_table on the FAIL path when --counters-json/--dump-counters is set and stats are populated. Backwards-compatible: downstream (run_regression.py, bench_diff.py) gates on result.get('ok') first, so adding 'stats' to error dicts is a strict superset. Concrete near-term use: lets you attribute work done by mid-flight-crashing benches (e.g. CADET_00001 IndexError after buffer-overflow phase fired 3 native CGC syscalls). Pattern to apply when adding more crash-prone benches or characterization tooling: don't gate stats collection on ok=True; the rust_mgr_instance counters survive an exception in solve.py since the Python exception unwinds the bench frame, not the engine state. Lands angr-qcsg, commit edde63e2b.
invariant-run-single-simos-not-callable-exclusion
remembered
run_single.py's Rust engine-swap monkeypatch (patched_simulation_manager) must exclude ONLY /angr/simos/ frames, not /angr/callable.py. The simos frame is where angr executes the load-time IFUNC/IRELATIVE resolver through a Callable (angr/simos/simos.py irelative_resolver + angr/simos/linux.py eager ifunc path) — those resolver states carry SYMBOL_FILL_UNCONSTRAINED_REGISTERS, which RustExplorationManager._check_raise_options rejects, so busybox_static would fail to load. Excluding all of callable.py (the pre-angr-zbpw0 state) also caught the Callables the BENCHES build, silently running mma_howtouse/flareon2015_10 entirely on the Python engine under --engine rust. Also: Callable.perform_call passes techniques=[]; RustExplorationManager does not accept it, so the patch must pop it (non-empty list => fall back to Python).
invariant-rust-as-cast-saturating
forgotten
Rust 'as' cast semantics for f32/f64 → integer types are saturating since Rust 1.45 (defined behavior, not UB): NaN→0, +inf→T::MAX, -inf→T::MIN, out-of-range→clamped to T::{MAX,MIN}. The native/angr/src/vex/ops.rs concrete branches for F32/F64→I32S/I64S/I32U/I64U conversions rely on this and are tested at vex::ops::tests with the names test_f32_to_i32s_{nan,pos_infinity,neg_infinity,overflow}. If Rust ever changes these semantics (very unlikely), those tests break and the closure bodies in ops.rs need explicit saturation logic.
invariant-rust-auto-init-to-main
remembered
RustExplorationManager auto-skips _start->main during construction via _run_python_init_if_needed (rust_manager.py:1449). It runs Python init from entry through __libc_start_main to main, caches the result on disk, then hands the post-main state to Rust. Saves ~180ms per run. Side effect: Rust manager's first exported active state is at main (e.g. 0x40071d for fauxware) while Python's entry_state is at _start (0x400580). Diff-state harnesses must align by address. Disabling the auto-init breaks Rust because it can't run __libc_start_main natively.
invariant-rust-callback-narrow-except
forgotten
RustExplorationManager Python callbacks have a consistent fallback contract: catch only typed errors and let everything else propagate. Memory/solver-shaped callbacks (_cb_memory_load, _cb_memory_store, _cb_fetch_page, _cb_sync_constraints, _cb_memory_store_batch, _cb_memory_load_batch, _cb_batch_fetch_pages, _cb_memory_store_symbolic_value) catch (SimError, ClaripyError). The lifter path (_cb_lift_block) catches (SimEngineError, ClaripyError, PyVEXError) — PyVEXError is independent of SimError. SimError is the common ancestor of SimMemoryError (state.memory.load/store) and SimSolverError (state.solver.eval/add). Bare 'except Exception' in these callbacks silently returns zero buffers / empty pages / sync_failed, masking real bugs. See angr-8e81 (initial 2 callbacks) and angr-2f7o (7 follow-up callbacks).
invariant-rust-callstack-order
forgotten
Rust call_stack stores frames in push-order (most recent at end of Vec). angr's CallStack plugin iterates top-most first. RustCallStackProxy reverses the Vec on construction so iter and frame.next walk most-recent-to-bottom. get_state_call_stack at native/angr/src/exploration/mod.rs:2296 returns Vec<(call_site_addr, callee_addr, return_addr, stack_ptr)> in push order — caller must reverse for angr-compatible top-first traversal.
invariant-rust-child-mod-privacy
forgotten
Rust private fields/methods of a struct defined in module M are accessible from M's CHILD modules without any pub(super). For the memory/{load,store,...}.rs split: load.rs accesses self.symbolic_objects, self.pages, self.endness, self.check_perms_range — all private — with no changes because load.rs is a child of memory/mod.rs. Only a private fn that needs to be called from a SIBLING child needs pub(super) (e.g. load_concrete_lazy_inner is called by store.rs through self.load_concrete_lazy() in load.rs, but store_strided in store.rs calls self.load_concrete_lazy directly — the inner fn is reachable via the public wrapper). Rule: when splitting an impl across child modules, private fields are auto-shared; private fn used across sibling children needs pub(super).
invariant-rust-concrete-arith-must-wrap
remembered
Concrete integer arithmetic in the Rust VEX ops MUST use wrapping_/checked_ helpers, never bare / % + << *. Rust checks division overflow even in --release, and the workspace [profile.release] sets panic="abort", so any overflow panic (classic: i128::MIN / -1 in a signed DivMod) SIGABRTs the whole Python process — no catchable PanicException, no errored stash, all exploration lost (no catch_unwind in the step path). Z3 bvsdiv/bvsrem wrap, so wrapping_div/wrapping_rem match the symbolic path exactly. Fixed in divmod_double_to_single (native/angr/src/vex/ops_int_arith.rs) for DivModS128to64, angr-n0xru. Audit-driven bugs of this shape should grep other concrete arithmetic arms for bare operators.
invariant-rust-default-engine-flag
remembered
engine="rust" project default (angr-op0dn.14.6, commit d7ed74cc6): the flip swaps the MANAGER, not the SimEngine. AngrObjectFactory.init accepts engine="rust" as a STRING sentinel (any other string raises AngrError); it sets self._rust_default and then resolves default_engine_factory to UberEngine exactly as engine=None does, so block()/CFG/lifting paths are untouched. Env equivalent: ANGR_DEFAULT_ENGINE=rust, read at PROJECT-CONSTRUCTION time by rust_manager.rust_default_engine_env() (NOT at import, unlike _AUTO_DISPATCH_ENABLED/ANGR_RUST_AUTO which is import-time + setter) so monkeypatch.setenv works. simulation_manager()s use_rust_engine default is now the _ENGINE_DEFAULT sentinel (class _EngineDefault in factory.py), NOT False — needed to tell "caller said nothing" from explicit False. When _rust_default, _auto_engine_choice(project_default=True) BYPASSES the global rust_auto_dispatch_enabled() gate: opting a project in IS the switch. Factory getstate/setstate tuple grew to 5 fields (added _rust_default) — any new factory field must be added there too.
invariant-rust-dirty-pages-pending
remembered
INVARIANT: SymbolicMemory.dirty_pages (FxHashSet field in native/angr/src/memory/mod.rs) tracks per-state which pages were mutated since the last clear. Re-initialized empty on fork via the dirty_pages: FxHashSet::default() lines in SymbolicMemory's fork/clone constructors (mod.rs). Updated on every store via self.dirty_pages.insert(page_num) in the symbolic and concrete paths of memory/store.rs. get_dirty_pages() in mod.rs is the in-crate reader. Exposed for the PENDING state via _get_pending_dirty_pages and _clear_pending_dirty_tracking (exploration/pending_api.rs). The concrete-page reader is _pending_memory_load_page; the symbolic-page reader is _pending_memory_load_symbolic_page (pending_api.rs, added 2026-05-10 by angr-3tek.2). All three are USED by _replay_rust_dirty_pages (rust_state_sync.py) to invalidate-and-replay the cached Python SimState on every callback. Do not add yet another sync layer — this one is sufficient for NativeRead/NativeWrite. (Symbol-anchored per refactor-memory-sweep-rule; the original raw lines memory/mod.rs:88, :345, store.rs:92/103/132, pending_api.rs:227/231/431/438 all drifted ~40-60 by iter52.)
invariant-rust-engine-auto-dispatch-predicate
remembered
Engine dispatcher (angr-op0dn.14.5.2): factory.simulation_manager takes tri-state use_rust_engine (True=loud Rust, False=Python, None=auto). Auto routing predicate is rust_engine_eligible(project, states, kwargs) in angr/exploration/rust_manager.py — it reuses rust_unsupported_options + unsupported_rust_manager_kwargs + rust_supports_arch (native arch_supported pyfunction, engine.rs, backed by arch_from_name) + _unsupported_inspect_events. Auto mode is OFF by default; flip it with set_rust_auto_dispatch(True) or ANGR_RUST_AUTO=1 (angr-op0dn.14.6 will flip the default). Route is recorded on the returned manager as mgr.dispatch_reason. INVARIANT: add any new 'Rust refuses this' signal to rust_engine_eligible too, or the flip re-introduces a raise.
invariant-rust-engine-loud-failures
forgotten
When a Python feature is unsupported by the Rust symex engine, prefer raising NotImplementedError with a message pointing at the tracking bead over silent no-ops. Silent no-ops hide bugs in caller techniques: e.g. _NoOpInspectProxy used to swallow state.inspect.b() so taint trackers and tracer constraint hooks would register but never fire, silently corrupting analyses. Loud failures surface unsupported paths immediately so users can switch to use_rust_engine=False or add support. (angr-osuu, 2026-05-08)
invariant-rust-explore-batches-without-step
remembered
RustExplorationManager.explore() and .run() do NOT call self.step() per VEX block — they batch through Rust internally (_explore_with_addresses / _explore_with_predicates / _run_predicate_batch). Consequences for the tests/benchmarks/diff_state.py harness:
(1) A manager.step monkey-patch fires only once for the whole exploration, not once per step. To get per-step granularity, replace explore/run with a step(1) loop (set find/avoid via _rust_mgr.set_find_addrs etc., loop while has_active_states and _found_count < num_find). Pattern: _patch_rust_explore_to_single_step in diff_state.py.
(2) The step(1) loop must mirror live exploration's predicate handling. Production _explore_with_predicates / _explore_with_addresses re-evaluate callable find/avoid predicates after every step batch via _evaluate_predicates_on_active() (rust_state_cache.py:191). After manager.step(1), if getattr(manager, '_find_predicate', None) or getattr(manager, '_avoid_predicate', None), call manager._evaluate_predicates_on_active(). Otherwise stdout/state-based predicates (csgames2018, sym-write) silently never fire because Rust can't evaluate Python callables. Termination via manager._found_count() already counts both Rust-native and predicate-matched finds.
invariant-rust-explore-replacement-needs-predicate-eval
forgotten
diff-state harness in tests/benchmarks/diff_state.py needs to mirror the live exploration loop's predicate handling. Production _explore_with_predicates / _explore_with_addresses re-evaluate callable find/avoid predicates after every step batch via _evaluate_predicates_on_active() (rust_state_cache.py:191). Any harness that replaces RustExplorationManager.explore with its own step loop must do the same — otherwise stdout/state-based predicates (csgames2018, sym-write) silently never fire because Rust can't evaluate Python callables. Pattern: after manager.step(1), if getattr(manager, '_find_predicate', None) or getattr(manager, '_avoid_predicate', None), call manager._evaluate_predicates_on_active(). Termination via manager._found_count() already counts both Rust-native and predicate-matched finds.
invariant-rust-filesystem-no-python-sync
forgotten
RustSimState::file_system() has been a fully-realized field since at least 2026-06-01 — FileSystem struct at native/angr/src/state.rs:300+ has open/close/read/write/seek/dup/dup2/pipe methods, stdin/stdout/stderr pre-registered, fork-isolated via Arc. Bd issues mentioning 'FD-table plumbed through RustSimState' as a blocker (e.g. angr-vp19, angr-k3ol, angr-0hif.5 dup-fallthrough comment) refer to a different blocker: bidirectional sync between Rust FileSystem and Python state.posix.fd. The libc procedures at native/angr/src/procedures/fileops.rs (NativeOpen, NativeClose, NativeRead, NativeWrite, NativeSeek, NativeDup, NativeDup2, NativePipe) all mutate Rust-only without Python sync — that's the established precedent for any new FS-touching code.
invariant-rust-float-ops-concrete-only
forgotten
OBSOLETE for FAdd/FSub/FMul/FDiv/FSqrt/FFma/FFms/FCmpEq/FCmpLt/FCmpLe (commit 769d1ee54), RoundF32toInt/RoundF64toInt with rm operand (commit d0377abb9), all int<->FP and FP<->FP conversions (commit 37983a77b), and SSE scalar VFAddS/VFSubS/VFMulS/VFDivS/VFSqrtS/VFMaxS/VFMinS (commit fcf33c14c). All these ops now route through Z3 FP theory. Remaining concrete-only float ops: float_neg, float_abs (still bitwise XOR/AND of sign bit, no symbolic dispatch), and packed-vector multi-lane ops (VFAddV etc. if any exist).
invariant-rust-honored-simoptions
forgotten
Rust honored SimOptions (updated 2026-06-03, angr-tfic): added AVOID_MULTIVALUED_READS + AVOID_MULTIVALUED_WRITES to the previously-honored set (LAZY_SOLVES, ZERO_FILL_UNCONSTRAINED_MEMORY, APPROXIMATE_MEMORY_INDICES, SYMBOLIC_WRITE_ADDRESSES, STRICT_PAGE_ACCESS, ENABLE_NX, NO_IP_CONCRETIZATION, NO_SYMBOLIC_JUMP_RESOLUTION, NO_SYMBOLIC_SYSCALL_RESOLUTION, KEEP_IP_SYMBOLIC). The pair is gated in AddressConcretizer via should_avoid_multivalued_read/write — both check addr.as_u64().is_none() && option set. Hooks fire at SymbolicMemory::load_symbolic_unified/store_symbolic_unified plus the interpreter callback paths (try_rust_memory_load/store, load_symbolic_addr, handle_symbolic_store) so use_rust_memory=false also honors them. Read returns unconstrained_read_value (zero BV if zero_fill_unconstrained, else fresh BVS named symbolic_read_unconstrained_N from a process-wide AtomicU64). Write returns Ok / no-op. Raised-on-construction set is unchanged. Error message in _check_raise_options unchanged. PyO3 signature for configure_concretization_strategies: (use_approximate, read_range_limit=None, write_range_limit=None, symbolic_write_addresses=false, avoid_multivalued_reads=false, avoid_multivalued_writes=false).
invariant-rust-init-cache-user-store-leak
forgotten
RustExplorationManager._init_cache (class-level dict at angr/exploration/rust_manager.py near line 432) caches the post-Python-init state at main() per (binary_path) key. HISTORICAL BUG: prior to fix angr-5yxf (commit 86c460c85, 2026-05-08), if state.memory.store(addr, BVS(...)) was called on the input state before constructing the manager, that store survived through _step_python_to_main and ended up in the cached post-init state. Later cache hits via cached.copy() + _apply_state_metadata inherited the prior user stores while losing their own — _apply_state_metadata at rust_manager.py:1695 copies constraints/globals/options but NOT memory pages.
CURRENT STATE (post-fix): both in-memory and disk init caches are gated by _state_has_user_symbolic. The disk path uses _compute_disk_init_key and the in-memory path uses _compute_mem_init_key (both return '' to disable caching when the input state has user-created symbolic data). _run_python_init_if_needed computes mem_key + disk_key once and forwards them to read+write paths. So mixing user-symbolic states across manager constructions on the same binary is now safe.
If you ever see the symptom (user stores returning <BV W 0x0> across tests/runs), check whether state_has_user_symbolic correctly detects the store you made — the scan covers stack page near SP and any page with .symbolic_data, looking for variable names that don't start with mem/reg_/unconstrained. Stores via state.memory.store(addr, BVS('myvar', ...)) match because 'myvar' doesn't start with those prefixes. The TestEdgeCases._isolate_class_caches autouse fixture (tests/engines/test_rust_exploration.py) clears the cache between tests as defense-in-depth even though the production code is now correct.
invariant-rust-inspect-unsupported
forgotten
state.inspect under the Rust engine dispatches 7 events as of 2026-05-22 (angr-d46u, commit 46a7f097a): mem_read (after, IRExpr::Load), mem_write (after, IRStmt::Store), reg_read (after, IRExpr::Get), reg_write (after, IRStmt::Put), instruction (before, IRStmt::IMark), irsb (before, execute_block_with_callbacks entry), exit (before, IRStmt::Exit). The dispatcher (rust_manager.py _dispatch_inspect_event) is now generic — takes **attrs kwargs. Per-event cb_inspect on RustExplorationManager build their own SimInspector attrs dict. Bitmask layout in _INSPECT_EVENT_BITS: mem_read=0, mem_write=1, reg_read=2, reg_write=3, exit=5, instruction=6, irsb=7 (bit 4 reserved for future fork). Bits 0..=5 mirror crate::state::InspectEvent; bits 6,7 are custom (no InspectionManager enum slot). Other events (call, fork, return, syscall, constraints, simprocedure, dirty, address_concretization, expr, statement, tmp_read, tmp_write, vex_lift, symbolic_variable, engine_process, memory_page_map) still raise NotImplementedError at registration. See docs/advanced-topics/rust_engine.rst for user-facing docs.
invariant-rust-last-time-drift
forgotten
RustSimState.last_time: Option tracks the most recent time(2) return value across the same state lineage; mirrors state.globals['sys_last_time'] from Python's procedures/linux_kernel/time.py. Cloned in all five fork/merge sites in state.rs. Drift class: same as posix_brk/mmap_base (not synced with Python state.globals on syscall fallback). When adding a Python-fallback path that runs Python's time() procedure and returns to native execution, the Python globals update is invisible — last_time keeps stale data. Acceptable today because Rust handles time() concretely-pointer cases natively.
invariant-rust-log-no-env-logger
forgotten
OBSOLETE as of 2026-06-03 / commit a5599b614 (angr-c242). The hand-rolled StderrLogger was replaced with a stderr logger backed by env_logger::filter::Filter (env_logger 0.8, default-features=false — only the filter module is pulled in). RUST_LOG is now honored at first RustExplorationManager construction (Python wrapper _apply_rust_log_env reads RUST_LOG first, then falls back to ANGR_RUST_LOG). set_rust_log_level() accepts both single-word levels (error/warn/info/debug/trace/off — typo'd one-word levels still raise ValueError) and full RUST_LOG-style specs containing '=' or ',' (e.g. 'rustylib::stash=warn,off' — handed straight to env_logger's parser). Note Rust modules surface as rustylib::* not angr::* because the crate is named rustylib. Output format '[rust:LEVEL] target: msg' preserved across the swap. Logger held in OnceLock with inner parking_lot::RwLock — first call installs the log::set_logger, subsequent calls swap the filter in place.
invariant-rust-manager-docstring-canonical
forgotten
After angr-wqao.3 (closed 2026-05-09, commit 30e682208), the canonical narrative source for cross-mixin invariants in the Rust exploration stack is the docstring at the TOP of angr/exploration/rust_manager.py — not a separate INVARIANTS.md (per CLAUDE.md preference). Ten invariants are listed there with regression-test citations: I1 disk-cache key axes, I2 init pipeline phases, I3 user-symbolic init-cache gate, I4 _apply_state_metadata option allowlist, I5 register filter, I6 state-cache pinning, I7 Rust↔Python max() sync, I8 exploration termination, I9 push_to_active_or_drop helper, I10 mgr.stats is a @property. When you add or change behavior that crosses mixin boundaries, update the docstring AND the matching bd memory (the bd memories remain the long-form references).