Skip to content

Replace the C runtime with map2check-library (Rust) - #76

Merged
hbgit merged 44 commits into
developfrom
feat/rust-runtime
Oct 17, 2026
Merged

hbgit merged 44 commits into
developfrom
feat/rust-runtime

Conversation

@hbgit

@hbgit hbgit commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Migração direta do runtime C (modules/backend/library) para a biblioteca Rust map2check-library, conforme o plano de cutover (map2check-library: docs/superpowers/plans/2026-10-15-cutover-runtime-c-map2check.md).

Em draft. Task 5.1, passos 1–3 feitos (segundo gate: PASS, ensaio de rollback, README/CHANGELOG); falta o mantenedor tirar de draft e mergear com merge commit (passos 4–6).

O que muda

  • CMake constrói/instala o runtime Rust (cmake/Map2CheckRustRuntime.cmake, MAP2CHECK_RUST_LIB_DIR / MAP2CHECK_RUST_SRC / MAP2CHECK_RUST_KLEE_BC). Versão mínima da biblioteca: 1.1.0-rc1 (README, seção de build). Um diretório só com o libmap2check.a núcleo (sem libfuzzer/wasm) agora falha no configure.
  • Os passes LLVM emitem a API núcleo (RuntimeABI.hpp/.cpp); o modo de análise é escolhido em tempo de execução (map2check_set_mode).
  • Caller::linkLLVM liga o runtime Rust: afl via afl-clang-fast -fsanitize=fuzzer, symex via o bitcode KLEE da biblioteca; o programa é compilado com -fexceptions.
  • --btree vira no-op com aviso (D3).
  • O código C continua no repositório até a Fase 6; tests/unit/backend-library sai do ctest.

Validação diferencial (Fase 4)

scripts/cutover/run-corpus.sh + scripts/cutover/compare.py, 410 linhas por lado, 5 repetições, docker --memory 3g --cpus 4, KLEE --max-memory=2000.

  • Gate 1: gate: PASS, mediana de tempo 1.011, mediana de RSS 1.081 (limite 1.36, D4).
  • Gate 2 (Task 5.1, 2026-10-17), segundo PASS consecutivo: lado Rust re-executado do zero em f7f15e3b, ligado ao lib/libmap2check_runtime.a do release v1.1.0-rc1 (sha256 verificado) e ao .bc KLEE de scripts/runtime/map2check-klee-bc.sh; afl + symex, 5 repetições; C = baseline-c-gate.tsv (o merge-base não mudou, rebase no-op). compare.py … baseline-rust-gate2.tsv --accepted deltas-aceitos.tsv: median wall ratio: 1.026 median rss ratio: 1.017 → gate: PASS. Nenhuma divergência nova; nenhum delta acrescentado. Uma linha (tree_stack, symex) teve repetições discordantes (moda UNKNOWN, como no C); re-execução diagnóstica deu UNKNOWN 5/5. Detalhes em gate-report.md, seção "Gate 2".
  • Deltas aceitos em tests/cutover/deltas-aceitos.tsv (19 linhas: ganhos de orçamento, UB do benchmark list-ext, SD-20 corrida do arquivo de propriedade no C, SD-21 OOM do C no afl).
  • Relatório: tests/cutover/gate-report.md (inclui os perfis do critério 9).

Rollback

Merge com merge commit; git revert -m 1 <merge> restaura o runtime C. Tag de preservação: legacy/c-runtime-final.

Ensaio (Task 5.1, passo 2, 2026-10-17): num clone descartável, develop (f01e8c3e) + git merge --no-ff feat/rust-runtime (f7f15e3b), depois git revert -m 1 HEAD. A árvore resultante é idêntica à de develop (git diff f01e8c3e HEAD vazio); repetido só em git para f00d33e6 (acrescenta a tabela e o relatório do gate 2), com o mesmo resultado. Build completo (KLEE + AFL++, -DENABLE_TEST=ON) na imagem C de develop (map2check-dev:cutover-f01e8c3e): instala de novo os .bc do runtime C e nenhum libmap2check.a; ctest: 10/10 passam. Nada foi publicado a partir do clone.

hbgit added 30 commits October 15, 2026 19:01
…ests

RuntimeABI.hpp/.cpp is the single place that names the runtime functions
the passes emit: every RuntimeHook maps to a core symbol of
map2check-library and its exact LLVM type, as given by
docs/cutover/abi-mapping.tsv and map2check.h. Hooks whose core is '-'
(the _uint binops, check_deref, overflowError) are not in the table.

tests/unit/pass checks the table against a vendored copy of the mapping
(RuntimeABI.*) and, per hook, the IR each pass emits (PassIR.*). The
PassIR tests of the passes are red until each pass is migrated.
kProgramUnwindFlags names the -fexceptions flag of D1 condition 11.
TrackBasicBlockPass calls the core map2check_save_basic_block_log(line,
function) through RuntimeHook::TrackBB instead of the C runtime's
map2check_track_bb; the argument order is unchanged and the returned
bbtrack_log_t* is ignored.

The plugin could not be loaded by opt: it referenced
SourceCodeHelper::getLine without linking tools.cpp, and its default
constructor (the one the plugin registration uses) leaves the source
helper null, which the empty-line walk dereferenced. Link tools.cpp and
log.cpp into the plugin and treat every line as non-empty when there is
no source helper.
AssertPass calls the core map2check_is_valid_assert(line, function,
expression) through RuntimeHook::Assert instead of the C runtime's
map2check_assert(cond, line, function): the condition moves from first
to last. __assert_fail still passes its line and function with a
constant-false condition. A __VERIFIER_assert declared with a non-i32
integer parameter passes "condition != 0" widened to i32.
TargetPass calls the core map2check_reach_target(function, scope, line)
through RuntimeHook::TargetReached instead of the C runtime's
map2check_target_function; the argument order is unchanged. The core
records TARGET-REACHED and returns instead of aborting.
LoopPredAssumePass calls the core __VERIFIER_assume(i32) through
RuntimeHook::Assume instead of the C runtime's map2check_assume_loop(i1):
the negated loop predicate is zero-extended from i1 to i32.
…kLibrary

Map2CheckLibrary calls the core map2check_set_mode(<mode>) and then
map2check_init() at the entry, and map2check_success() before returns,
exit and abort, through RuntimeHook::SetMode/Init/Success. init no
longer takes the dead isSvComp argument of the C runtime.

The mode comes from the new -m2c-analysis-mode option (none, memtrack,
memcleanup, overflow, assert, target; AnalysisMode values 0-5, default
none). The frontend passes it for every run: cover-branches is none and
reachability is target. The C runtime got the mode from the
AnalysisMode*.bc it linked; the Rust runtime is one library for every
mode.

LibraryFunctions.hpp, whose only remaining user was this pass, is gone
with the C names it declared.
…tions

map2check-library rejects a fuzzer input whose __VERIFIER_assume fails
by unwinding through the program's frames (D1 condition 11). Without
-fexceptions clang marks every C function nounwind, and unwinding
through a nounwind function is undefined in LLVM IR; the attributes
travel in the bitcode, so the flag belongs on the -emit-llvm step.

Every place where the program's C becomes bitcode now passes
Map2Check::kProgramUnwindFlags: the -compiled.bc compile, the weak
target stub linked after slicing, the wasm entry wrapper and both
WasmLifter clang runs. No step adds -fno-unwind-tables or
-fno-asynchronous-unwind-tables. The Clam route (--add-invariants)
compiles through clam.py and is not changed.
runtimeAnalysisMode moves from a Caller static to an inline function in
caller.hpp, next to the new kDefaultMode (MEMTRACK, the mode of a run
without a mode flag, now used by map2check.cpp). AnalysisModeTest checks
that every frontend mode maps to its number (MEMTRACK 1, MEMCLEANUP 2,
OVERFLOW 3, ASSERT 4, REACHABILITY 5, COVER_BRANCHES 0, default 1), and
PassIR/FrontendMode feeds each value through opt and checks the
constant map2check_set_mode receives.
clam.py compiles the C itself and cannot forward clang flags, so with
--add-invariants (ENABLE_CLAM=ON, reachable by AFL++) the program
bitcode was nounwind, against D1 condition 11. compileWithClam now
compiles the preprocessed C with the flags clam.py would use
(-disable-O0-optnone, -fdeclspec, -fno-discard-value-names, -m64, -g,
its include dir) plus kProgramUnwindFlags, and hands clam.py the
bitcode, for which it skips its own clang step. On a loop program the
same 8 verifier.assume calls are injected, and main is no longer
nounwind.
…OverflowPass

OverflowPass now declares map2check_binop_{add,sub,mul,div,rem}_signed
through RuntimeABI and calls them with the core order (a, b, bits, line,
fn): operands sign-extended to i64, the operation's own width, no scope.
The _uint variants (C no-ops) and the dead overflowError path are gone,
and OperationsFunctions.hpp with them. The unsigned-nondet name check
follows the core generator __VERIFIER_nondet_uint.

PassIR tests cover every binop (callee, type, operands, bits, line,
function) and the widths 8/16/32/64.
…rom NonDetPass

NonDetPass keeps the core generators __VERIFIER_nondet_<t> (retargeting
__VERIFIER_nondet_unsigned to __VERIFIER_nondet_uint per call, so later
calls are still logged as unsigned) and logs each value through
map2check_save_nondet_log_<t>(line, scope, type_var, slot, fn): the value
is stored in an entry alloca of the C type of the library's api-deltas
(bool widens to int) and type_var is the NONDET_TYPE number. The slot
and its store carry map2check.runtime metadata.

Assumes: __VERIFIER_assume keeps its name; Clam's nounwind
verifier.assume(i1) is replaced by a zext call to __VERIFIER_assume;
assume_abort_if_not and friends call it with cond != 0; a program's own
__VERIFIER_assume loses its body so the library's is the only one. The
library's assume unwinds, so nounwind is dropped from its declaration,
from the rewritten functions and from all their call sites
(RuntimeABI allowUnwind).

NonDetFunctions.hpp is gone. RuntimeABI gains nonDetValueSlot,
allowUnwind and kRuntimeMetadata, with RuntimeABITest coverage; PassIR
tests cover the 16 types, the C names, the assume rewrites and nounwind.
When the program already declares __VERIFIER_assume nounwind, the loop
predicate calls reuse that declaration. The library's assume unwinds
through them, so the pass now clears nounwind on the declaration and
every call (RuntimeABI allowUnwind).

loop_assume.ll carries such a declaration; it also backs the NonDetPass
nounwind test, since clang -O0 leaves external declarations without
nounwind and assume.c could not show the fix.
…Pass

Every runtime call of MemoryTrackPass goes through RuntimeABI with the
core order and widths (sizes, lines and scopes are i32):

- malloc/valloc/calloc/realloc/posix_memalign -> map_malloc, map_calloc,
  map_posix_memalign; realloc checks its old block with
  check_free_resolved_address (is_null_valid i16 1);
- globals, allocas and alloca() -> map_alloca; a VLA -> map_non_static_alloca
  with its element count;
- loads, stores, memset, memcpy -> check_load(ptr, line, scope, size, fn),
  which records the violation itself, so check_deref is gone;
- stores of pointers -> map_store_pointer;
- free(var) -> load *slot, skip NULL, then check_free and map_free, both
  (name, slot, *slot, scope, line, fn), guarded after the walk;
  free(expr) -> check_free_resolved_address (is_null_valid 0);
- the wasm2c hooks keep their names (wasm_free takes an i64 line).

Instructions tagged map2check.runtime (NonDetPass's value slots) are not
tracked. The dead, type-unsafe instrumentFunctionArgumentAddress is gone.
PassIR tests cover each hook's callee, type and arguments, the free
guard, and the wasm mode.
Deleting its body left an empty function to the passes that run after
nondet-pass in the same pipeline, and Map2CheckLibrary crashed opt on it
(found by the end-to-end link). NonDetPass now renames the program's
definition to an internal, unused __map2check_program_assume and sends
every use to the core __VERIFIER_assume declaration, so the library's
definition is still the only one. The PassIR test runs
nondet-pass,map2check-library to cover the crash.
EndToEndLinkTest (Task 3.2 Steps 5 and 5b), one ctest entry per mode and
input. Each input of tests/unit/pass/inputs is compiled as the frontend
compiles the program (-c -emit-llvm -g -fexceptions -O0), instrumented
with every pass of the mode as Caller::callPass chains them (nondet-pass,
the mode's pass, map2check-library with -m2c-analysis-mode), and linked
with clang-16 -fexceptions -fsanitize=fuzzer against the Rust runtime;
the link must succeed with no undefined reference and no multiple
definition. The modes are memtrack, memcleanup, overflow, assert, target
and none.

EndToEndRun.AssumeRejectionsUnwindAtO2UnderLibFuzzer builds
assume_runs.c at -O2, checks that no frame the unwind crosses is
nounwind, and runs 100 rejected inputs and then one accepted input
through the libFuzzer driver: exit 0, 101 executions, only the accepted
input prints, and no abort or panic message.

target.c now defines reach_error and wasm.ll defines its allocators, so
both inputs are whole programs. The library path comes from
MAP2CHECK_RUST_LIB_DIR (or MAP2CHECK_RUST_SRC); without either the test is
skipped.
free(var) loaded *slot a second time (%map2check.freed) for the NULL
guard, check_free and map_free. The block is now the program's load,
the very value free receives; it dominates the call, so it also
dominates the guarded block. One load per free, as in the C runtime;
under KLEE a symbolic slot no longer costs a second resolution.

The PassIR test requires check_free and map_free to get that same SSA
value and finds no other load of the slot up to the free.
…trumenting

SV-COMP programs define library exports themselves, and the link saw
two definitions (multiple definition of __VERIFIER_assert, ...).
RuntimeABI now keeps the two lists and resolveProgramDefinitions(module)
applies them, idempotently, as the first step of NonDetPass (first in
every pipeline) and again in Map2CheckLibrary:

- discarded (__VERIFIER_assume and the 16 exported __VERIFIER_nondet_*):
  every use goes to a declaration of the same name, so calls bind to the
  library (fuzzer/KLEE input, the pruning assume); the old body becomes
  an unused internal __map2check_program_<name> holding unreachable.
  This generalises 072afa6, whose __VERIFIER_assume branch moves here;
- internalized (__VERIFIER_assert, __VERIFIER_error, get_current_step,
  get_next_step, get_current_property, set_false_result,
  print_all_containers_as_json, vcc_reset_meta_data): the program keeps
  its body with internal linkage under the same name, so AssertPass still
  matches __VERIFIER_assert call sites by name.

The DebugInfo and RuntimeABI object libraries are now built with
position-independent code; the plugins are shared objects and the lists'
std::vector code needs it.

Tests: PassIR checks both treatments, with the definitions before and
after main; RuntimeABITest checks the lists. svcomp_defs.c and
svcomp_defs_late.c join the end-to-end matrix (every mode links, 0
undefined, 0 duplicate), and EndToEndRun.ProgramDefinedExportsGiveWay-
ToTheLibrary shows at -O2 under libFuzzer that input 7 is pruned by the
library's assume and input 123456 reaches a branch the program's
return-0 generator never would, while the internalized
get_current_step still runs its own body.
The frontend links the Rust runtime (decision D1) and no bitcode of the
C runtime, WasmRuntimeStubs.bc included (the library's wasm feature
provides wasm_rt_*):

- afl/none (route B1): the -fuzzed, -witness-fuzzed and -cmplog binaries
  are afl-clang-fast -fsanitize=fuzzer <hash>-result.bc
  ${MAP2CHECK_PATH}/lib/libmap2check.a -ldl -lpthread -lm, with the
  previous -g/-O2 flags; CmpLog runs under `env AFL_LLVM_CMPLOG=1`. The
  library's LLVMFuzzerTestOneInput drives __map2check_main__.
- symex (route A'): llvm-link of <hash>-output.bc with
  ${MAP2CHECK_PATH}/lib/map2check-klee.bc. --nondet-generator symex on an
  install without that file exits with code 3 and says how to fix it; the
  hybrid default skips its KLEE phase with an error message.
- Witness (Step 4b): the witness binary and the witness bitcode also link
  a generated constructor that calls map2check_enable_witness(), native
  for AFL++ and -emit-llvm for KLEE, both with -fexceptions.
- Crashes are replayed on the witness binary with the file as an
  argument, not on stdin (D1 condition 2).
- The afl-fuzz placeholder seed is 8 bytes, the widest nondet value, so
  CmpLog finds a whole operand in it (D1 condition 4); a seed directory
  that already holds seeds is left untouched.
- --btree is a no-op that logs "--btree is ignored by the Rust runtime"
  (provisional, decision D3).

The command builders are pure functions in caller_link.cpp, covered by
CallerLinkTest over every mode and generator, wasm, witness and CmpLog.
Also include <cstdlib> where caller_link.cpp calls getenv.
Task 4.3 measures the C runtime with --btree. EXTRA_FLAGS is appended to
the per-property flags; unset, the command line is unchanged.
The gate runs each side under a container memory cap. A new oom_kill in
memory.events during a task without a decided verdict is written as OOM;
a decided verdict is kept and the kill is reported on stderr.
Rust (lib 44585e8) and C (f01e8c3) baselines, GENS="afl symex" REPS=5,
the C --btree run on valid-memsafety (REPS=3), both sides capped at
3 GiB / 4 CPUs with KLEE --max-memory=2000. The gate fails: three C
defects (property-file race under KLEE, afl OOM) and the median RSS
ratio of 1.36. Proposed accepted deltas cover only budget gains and
the list-ext benchmark UB.
hbgit added 14 commits October 16, 2026 22:22
15 symex rows: KLEE spends about 2.6 s before the first instruction on
the 2.3 MB runtime module (about 1.9 s of it in --optimize); exploration
is as fast as C or faster. Deleting the std backtrace symbolizer bodies
halves that start-up and lowers KLEE's peak RSS from 221 to 161 MB.
2 afl rows: C's AFL++ link fails on __VERIFIER_assert and skips fuzzing,
Rust fuzzes and hits AFL++'s 1 s dry-run timeout. list-ext afl: extra
fuzzing that finds the target (accepted delta).
The map2check-library release archive ships three builds in lib/; the one
Map2Check links is libmap2check_runtime.a (profile runtime, features
libfuzzer,wasm), while its libmap2check.a is the featureless core build.
With MAP2CHECK_RUST_LIB_DIR pointing at that lib/, prefer the runtime
build and install it as lib/libmap2check.a, the name the frontend uses.

The three cache variables now default to the environment variables of the
same name, so an image can carry them. The resolved path is exported as
MAP2CHECK_RUST_RUNTIME_LIB and the module is included before the tests,
so EndToEndLinkTest links the same file that is installed.
Fetch map2check-library v1.1.0-rc1 (SHA-256 pinned in
scripts/runtime/fetch-map2check-library.sh) into /opt/map2check-library
and configure every CMake build with -DMAP2CHECK_RUST_LIB_DIR. The KLEE
runtime bitcode is not in the archive: a new job builds it from the same
tag (commit pinned) with rustc 1.72.1 and hands it to the Docker jobs as
-DMAP2CHECK_RUST_KLEE_BC, so the symex steps keep running.

Dockerfile.dev does the same: the archive in /opt/map2check-library, the
KLEE bitcode built and the Rust toolchain removed, and both paths set as
environment defaults for CMake.

Drop the cppcheck pass over modules/backend/library: the C runtime is out
of the build and stays in the tree only until it is removed.
scripts/runtime/build-map2check-klee-bc.sh matched the *build-* pattern of
.gitignore and never reached the branch; it is now map2check-klee-bc.sh.

cppcheck over tests/unit flags memleakOnRealloc in
tests/unit/pass/inputs/memtrack.c, a PassIRTest input whose
'p = realloc(p, ...)' is the pattern the realloc instrumentation is tested
on. Suppress that id for that file only.
The TestCov gate observed COVERED where expected.txt said NOT_COVERED
(finding K). The cause is the runtime: the C one formatted each symbolic
nondet value in uClibc's vfprintf, forking on every digit, while the Rust
KLEE runtime concretizes the value before writing it (D1 condition 7), so
the test vector comes out within the budget.
With --add-invariants and a .bc input the frontend still took
compileCFile, so Clam never ran. test_add_invariants.sh passed anyway:
it matched the name map2check_crab_assume anywhere in the scratch
bitcode, and the C runtime linked into the result bitcode defines it.
With the Rust runtime that definition is gone and the check failed.

compileWithClam now takes the bitcode as Clam's input as is, and the test
requires a call to verifier.assume or map2check_crab_assume, not just the
name.
The KLEE runtime layer ran 'curl https://sh.rustup.rs | sh' as root: the
toolchain was pinned, the installer was not. Download rustup-init 1.28.2
for x86_64-unknown-linux-gnu from static.rust-lang.org/rustup/archive,
check it against the SHA-256 of the .sha256 file published next to it,
and run it with the same options; a mismatch stops the build.
loop_reads.c checks sum == 10, which any order of its reads satisfies,
so it witnesses the count of the input sequence, not its order. The
README, its table and the program comment said otherwise.

Note that the environment defaults of the runtime cache variables apply
only on the first configure of a build directory, and raise the Valgrind
job timeout from 30 to 45 minutes: its ctest step now takes about 15.
…e changelog

README build section: MAP2CHECK_RUST_LIB_DIR, MAP2CHECK_RUST_SRC and
MAP2CHECK_RUST_KLEE_BC, and map2check-library 1.1.0-rc1 as the minimum
version. CHANGELOG: "Runtime replaced by map2check-library (Rust)".
MAP2CHECK_RUST_LIB_DIR without libmap2check_runtime.a used to fall back to
libmap2check.a silently. In the release archive that name is the
featureless core build, which lacks the libfuzzer and wasm entry points.
The fallback now requires the archive to define LLVMFuzzerTestOneInput and
map2check_wasm_malloc, as a local target/runtime build does, and configure
fails with a clear message otherwise. llvm-nm-16 cannot read the members
(rustc's newer bitcode), so every nm on the PATH is tried.

test_rust_runtime_cmake.sh: cases for the local layout (accepted) and for a
directory holding only a core build, synthetic and the release one
(rejected).
baseline-rust-gate2.tsv: HEAD f7f15e3 linked against the v1.1.0-rc1
release archive and the KLEE bitcode of scripts/runtime/map2check-klee-bc.sh,
afl and symex, 5 reps, same caps as gate run 1. compare.py against
baseline-c-gate.tsv with deltas-aceitos.tsv: gate PASS, median wall 1.026,
median RSS 1.017. The report notes one row with disagreeing reps
(tree_stack symex) and the frontend exit-137 hypothesis behind its SUCCEEDED rep.
The SRC route built target/runtime/libmap2check.a through a custom command
whose output, once present, was up to date: an archive left by a cargo build
without the libfuzzer/wasm features was installed as is. The route is now a
custom target that runs cargo on every build (cargo is incremental) and then
the same symbol check as the LIB_DIR fallback, moved to
cmake/Map2CheckRustRuntimeCheck.cmake (include() at configure time, cmake -P
after cargo). The check now tells "no nm could read the archive" apart
from "the symbol is not defined".

test_rust_runtime_cmake.sh: libfuzzer-only archives (synthetic and the
release one) rejected; no readable nm rejected with its own message; SRC
route with a stale core archive fails after running cargo, and a rebuilt
runtime archive passes with cargo run on every build. README: the SRC route
also builds the KLEE bitcode when rustc 1.72.1 and llvm-dis are present.
@hbgit
hbgit marked this pull request as ready for review October 17, 2026 20:54
@hbgit
hbgit merged commit a0f8b9c into develop Oct 17, 2026
11 checks passed
@hbgit

hbgit commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

Merge commit: a0f8b9c (pais f01e8c3 e 1f91659). Rollback: git revert -m 1 a0f8b9cd em develop restaura o runtime C (ensaiado: ctest 10/10). Tag de preservação: legacy/c-runtime-final.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant