Leaf
Welcome to project Leaf, a tool to dynamic analysis for Rust programs by MIR instrumentation.
Leaf aims to be robust, extensible, and easily-integrable within real world Rust testing stacks.
Please refer to Introduction for more details about the workflow of the tool, and try to perform your first dynamic analysis with Leaf to get a hands-on experience.
Introduction
Leaf is a framework for dynamic analysis of Rust programs built around MIR instrumentation. Instead of analyzing source code directly, Leaf compiles a program through leafc, rewrites the relevant MIR, and runs the resulting binary with a runtime backend attached.
That split keeps the responsibilities clear:
- the compiler frontend prepares the program for analysis,
- the runtime receives execution events and records or reacts to them, and
- higher-level tooling can orchestrate repeated runs or more advanced workflows.
At a high level, the workflow looks like this:
- write or choose a Rust program,
- compile it with Leaf’s instrumentation pipeline,
- run the instrumented binary with the desired backend, and
- inspect the emitted traces or analysis results.
This book starts with a practical getting-started guide, then moves into recipes and configuration details for common workflows. Use it as a reference when you need to understand how Leaf’s compiler, runtime, and orchestration pieces fit together.
First Dynamic Analysis with Leaf
Leaf is a Rust-oriented framework for dynamic analysis built around MIR instrumentation. The workflow is:
- compile and instrument a target program with
leafc, and - provide a runtime backend that receives callbacks from the instrumented program,
- run the instrumented program with the backend plugged in.
Requirements
- Rust
- Python
- A working C toolchain and linker
Installing Leaf
-
Clone the repository and enter the workspace.
$ git clone https://github.com/sfu-rsl/leaf.git $ cd leaf -
Install the compiler frontend.
$ cargo install --path ./compiler
Preparing Dynamic Analysis
-
Build a runtime backend, for example symbolic execution.
$ cargo build -p runtime_symex -
Make the runtime shared library discoverable to the instrumented binary.
$ mkdir -p target/debug/runtime_symex $ ln -sf "$(find target/debug -maxdepth 1 -name 'libleafrt*.so' | head -n 1)" target/debug/runtime_symex/libleafrt.so $ export LD_LIBRARY_PATH="$PWD/target/debug/runtime_symex:$LD_LIBRARY_PATH"
Analyzing a Program
Leaf ships with sample programs under the samples/ directory. A minimal example is the hello_world sample.
fn main() {
let x: u8 = core::hint::black_box(10);
#[cfg(leafc)]
let x: u8 = {
use leaf::annotations::*;
x.mark_symbolic()
};
if x < 5 {
println!("Hello, world!");
}
}
-
Compile the sample with
leafc.$ leafc samples/hello_world.rs -
Enable logging for the runtime.
$ export LEAF_LOG="info" -
Run the generated binary.
$ ./hello_world
You should see runtime events emitted by the active backend. The exact output depends on the chosen backend and logging configuration, but the execution should complete and produce instrumentation traces or analysis data. With the example symbolic execution backend in effect, an output similar to the following is expected.
2024-12-10 00:40:55 INFO leafrt Initializing runtime library
2024-12-10 00:40:55 INFO leafrt::pri::basic::instance Initializing basic backend
2024-12-10 00:40:55 INFO leafrt::backends::basic::outgen Setting up binary output writing to directory: output
2024-12-10 00:40:55 INFO leafrt::pri::basic::instance Basic backend initialized
2024-12-10 00:40:55 INFO leafrt::backends::basic::sym_vars Added a new symbolic variable: <Var1: u8> = 10u8
2024-12-10 00:40:55 INFO leafrt::trace::log Notified about constraint {!(<(<Var1: u8>, 5u8))} at step Def(0:5)[2]
2024-12-10 00:40:55 INFO leafrt::outgen Found a solution:
{
"1": 0u8,
}
Next steps
The rest of the book covers the compiler pipeline, runtime backends, and more advanced analysis workflows in greater detail.
Write a Counter Backend
This tutorial shows how to write a minimal Leaf backend and plug it into an instrumented program.
Goal
You will build a backend that counts assignments and run it through a Leaf-instrumented target.
The backend reports:
- total assignments
- unary assignment counts by
UnaryOp - binary assignment counts by
BinaryOp
You will do this in five steps:
- Define a minimal
RuntimeBackend. - Define an
AssignmentHandlerthat performs counting. - Add an
InstanceManagerfor the backend. - Package the backend as a dynamic library.
- Run an instrumented sample and verify the output.
Backend Design
Before starting the implementation, it helps to identify the core pieces of the backend.
The backend reports the total number of assignments, plus binary and unary assignment counts grouped by operation.
State
The information that a backend maintains about the running program is its state. For this backend, the state looks like this:
use std::collections::HashMap;
use leaf_runtime::abs::{BinaryOp, UnaryOp};
#[derive(Default)]
struct AssignStats {
total_assignments: u64,
binary_by_op: HashMap<BinaryOp, u64>,
unary_by_op: HashMap<UnaryOp, u64>,
}
Updating the State
We need to:
- increment
total_assignmentsfor every assignment - increment the appropriate map for every unary or binary assignment
This behavior can be represented as follows:
struct Counter<'a> {
stats: &'a mut AssignStats,
}
impl Counter<'_> {
fn binary_op_between(mut self, op: BinaryOp) {
*self.stats.binary_by_op.entry(op).or_default() += 1;
self.some()
}
fn unary_op_on(mut self, op: UnaryOp) {
*self.stats.unary_by_op.entry(op).or_default() += 1;
self.some()
}
fn some(mut self) {
self.stats.total_assignments += 1;
}
}
This is the core functionality that the backend must provide. Next, we map it to Leaf’s backend model.
Realizing the Backend
Step 1: Define a Backend
Tip
Boilerplate code is hidden by default, which can be displayed by clicking on the eyeball () button.
An implementation of a backend follows the contract defined by RuntimeBackend.
Define the backend as follows:
use leaf_runtime::{
abs::{AssignmentId, BasicBlockIndex, BinaryOp, PlaceUsage, UnaryOp, backend::Shutdown},
pri::fluent::backend::{RuntimeBackend, shared::noop::*},
};
struct CounterBackend {
// Components that will live during the execution.
}
impl RuntimeBackend for CounterBackend {
// TODO
type PlaceHandler<'a> = /* ... */
where
Self: 'a;
type OperandHandler<'a> = /* ... */
where
Self: 'a;
type AssignmentHandler<'a> = /* ... */
where
Self: 'a;
type MemoryHandler<'a> = /* ... */
where
Self: 'a;
type RawMemoryHandler<'a> = /* ... */
where
Self: 'a;
type ConstraintHandler<'a> = /* ... */
where
Self: 'a;
type CallHandler<'a> = /* ... */
where
Self: 'a;
type DropHandler<'a> = /* ... */
where
Self: 'a;
type AnnotationHandler<'a> = /* ... */
where
Self: 'a;
type PlaceInfo = /* ... */;
type Place = /* ... */;
type DiscriminablePlace = /* ... */;
type Operand = /* ... */;
fn place<'a>(&'a mut self, _usage: PlaceUsage) -> Self::PlaceHandler<'a> {
Default::default()
}
fn operand<'a>(&'a mut self) -> Self::OperandHandler<'a> {
Default::default()
}
fn assign_to<'a>(
&'a mut self, _id: AssignmentId, _dest: Self::Place,
) -> Self::AssignmentHandler<'a> {
Default::default()
}
fn memory<'a>(&'a mut self) -> Self::MemoryHandler<'a> {
Default::default()
}
fn raw_memory<'a>(&'a mut self) -> Self::RawMemoryHandler<'a> {
Default::default()
}
fn constraint_at<'a>(&'a mut self, _loc: BasicBlockIndex) -> Self::ConstraintHandler<'a> {
Default::default()
}
fn call_control<'a>(&'a mut self) -> Self::CallHandler<'a> {
Default::default()
}
fn dropping<'a>(&'a mut self) -> Self::DropHandler<'a> {
Default::default()
}
fn annotate<'a>(&'a mut self) -> Self::AnnotationHandler<'a> {
Default::default()
}
}
MIR contains several kinds of events that a backend can handle. This backend only needs assignments, so no-op definitions are sufficient for the other event types.
type PlaceHandler<'a>
= NoOpPlaceHandler
where
Self: 'a;
// Similar for other associated types
type OperandHandler<'a>
= NoOpOperandHandler
where
Self: 'a;
type AssignmentHandler<'a>
= NoOpAssignmentHandler
where
Self: 'a;
type MemoryHandler<'a>
= NoOpLifetimeHandler
where
Self: 'a;
type RawMemoryHandler<'a>
= NoOpRawMemoryHandler
where
Self: 'a;
type ConstraintHandler<'a>
= NoOpConstraintHandler
where
Self: 'a;
type CallHandler<'a>
= NoOpCallHandler
where
Self: 'a;
type DropHandler<'a>
= NoOpDropHandler
where
Self: 'a;
type AnnotationHandler<'a>
= NoOpAnnotationHandler
where
Self: 'a;
type PlaceInfo = NullPlaceInfo;
type Place = NullPlace;
type DiscriminablePlace = NullPlace;
type Operand = NullOperand;
Note
We explain each element elsewhere in the book. To keep this tutorial focused, treat these associated types and methods as holes filled by no-op definitions.
The backend instance owns the state, so add it:
#[derive(Default)]
struct CounterBackend {
stats: AssignStats,
}
Print the statistics when the runtime shuts down:
impl Shutdown for CounterBackend {
fn shutdown(&mut self) {
println!(
"total assignments: {}\nbinary: {:?}\nunary: {:?}",
self.stats.total_assignments, self.stats.binary_by_op, self.stats.unary_by_op,
);
}
}
Step 2: Handling Assignments
Now we add the counting behavior shown earlier as an implementation for AssignmentHandler.
struct CounterAssignmentHandler<'a> {
stats: &'a mut AssignStats,
}
impl AssignmentHandler for CounterAssignmentHandler<'_> {
type Place = NullPlace;
type Operand = NullOperand;
fn binary_op_between(self, op: BinaryOp, _a: Self::Operand, _b: Self::Operand) {
*self.stats.binary_by_op.entry(op).or_default() += 1;
self.some()
}
fn unary_op_on(self, op: UnaryOp, _operand: Self::Operand) {
*self.stats.unary_by_op.entry(op).or_default() += 1;
self.some()
}
// Catch-all for other assignment forms.
fn some(self) {
self.stats.total_assignments += 1;
}
}
Then installing it in the backend:
impl RuntimeBackend for CounterBackend {
type AssignmentHandler<'a>
= CounterAssignmentHandler<'a>
where
Self: 'a;
fn assign_to<'a>(
&'a mut self,
_id: AssignmentId,
_dest: Self::Place,
) -> CounterAssignmentHandler<'a> {
CounterAssignmentHandler { stats: &mut self.stats }
}
}
A few details are worth mentioning, although they are not specific to this backend:
- The traits to implement for a backend and its handlers are defined for working in
FluentPri.- The parameters given in an interface call chain provide the representation of the pieces in the original MIR event. For instance, in an assignment based on a unary operation like
_5 = Neg(move _4);, a call chain ofassign_to(ID_X, p_dest).unary_op_on(UnaryOp::Neg, p_operand)is expected whereID_Xcorresponds to the unique id for this assignment in its parent body,p_destandp_operandcorrespond to place representations for locals_5and_4.- Handler components of a backend are designed to be short-lived instances that provide the expected interface. All durable information (e.g., program state) should be owned by the backend itself and borrowed by the handlers.
Step 3: Add an InstanceManager and Pri
InstanceManager
An instance manager constructs, provides access to, and destroys backend instances for probes. Probes can run at any point during execution and in any function in the program. In many cases, a simple instance manager that wraps a globally allocated backend instance suffices.
This tutorial does not explain the lower-level details of this trait. Use the following implementation as a template, and see the rest of the book for details.
mod instance {
use std::sync::{Mutex, Once};
use leaf_runtime::pri::{fluent::InstanceManager, refs::NoOpRefManager};
use super::*;
static BACKEND: Mutex<Option<CounterBackend>> = Mutex::new(None);
static mut PLACE_REF_MANAGER: NoOpRefManager<NullPlace> = NoOpRefManager::new(());
static mut OPERAND_REF_MANAGER: NoOpRefManager<NullOperand> = NoOpRefManager::new(());
static INIT: Once = Once::new();
pub(crate) struct CounterInstanceManager;
impl InstanceManager for CounterInstanceManager {
type PlaceInfo = NullPlace;
type Place = NullPlace;
type Operand = NullOperand;
type Backend = CounterBackend;
type PlaceBuilder = NoOpPlaceBuilder<NullPlace, NullPlace>;
type PlaceRefManager = NoOpRefManager<NullPlace>;
type OperandRefManager = NoOpRefManager<NullOperand>;
fn init() {
INIT.call_once(|| {
let mut guard = BACKEND.lock().unwrap();
let backend = CounterBackend::default();
*guard = Some(backend);
});
}
fn deinit() {}
fn perform_on_backend<T>(action: impl for<'a> FnOnce(&'a mut Self::Backend) -> T) -> T {
let mut guard = BACKEND.lock().unwrap();
let backend = guard.as_mut().expect("Runtime is not initialized.");
action(backend)
}
#[allow(static_mut_refs)]
fn perform_on_place_ref_manager<T>(
action: impl FnOnce(&mut Self::PlaceRefManager) -> T,
) -> T {
action(unsafe { &mut PLACE_REF_MANAGER })
}
#[allow(static_mut_refs)]
fn perform_on_operand_ref_manager<T>(
action: impl FnOnce(&mut Self::OperandRefManager) -> T,
) -> T {
action(unsafe { &mut OPERAND_REF_MANAGER })
}
}
}
Exporting a PRI
The final step in the backend crate is to define the PRI implementation that the flavor exports through Leaf’s C ABI.
pub mod interface {
use leaf_runtime::pri::fluent::FluentPri;
type CounterPri = FluentPri<super::instance::CounterInstanceManager>;
leaf_runtime::make_late_init_pri_of!(CounterPri);
pub type DefaultPri = CounterPriLateInit;
}
Step 4: Package as a Dynamic Library
To publish the backend as libleafrt.so, which can be loaded by an instrumented program, define a flavor as a separate crate and use the project template.
For this tutorial, copy an existing flavor under runtime/flavors and point its backend dependency at the backend crate in Cargo.toml.
[package]
name = "runtime_counter"
license = { workspace = true }
version = { workspace = true }
edition = "2021"
[lib]
name = "leafrt_counter"
crate-type = ["cdylib"]
[dependencies]
common = { workspace = true }
backend = { path = "../../backends/counter", package = "runtime_backend_counter" }
The flavor’s build.rs sets the shared library’s SONAME to libleafrt.so, and its src/lib.rs exports the backend through the common FFI template:
include!("../shared_build.rs");
fn main() {
set_so_name();
}
type PriImpl = backend::interface::DefaultPri;
include!("../../ffi_template.rs");
Step 5: Run and inspect output
Build the flavor from the repository root:
$ cargo build -p runtime_counter
Follow the same steps as in the other tutorial to load the dynamic library.
Now compile and run the instrumented sample with leafc:
For example, instrument the following program.
fn main() {
let mut x: i8 = core::hint::black_box(20);
if x < 5 {
x += 1;
} else {
x -= 1;
}
x = -x;
core::hint::black_box(x);
}
Save the example as counter_sample.rs, then run:
$ leafc counter_sample.rs
$ ./counter_sample
MIR
fn main() -> () {
let mut _0: ();
let mut _1: i8;
let mut _2: bool;
let mut _3: i8;
let mut _4: i8;
let _5: i8;
scope 1 {
debug x => _1;
scope 3 (inlined std::hint::black_box::<i8>) {
debug dummy => _1;
}
}
scope 2 (inlined std::hint::black_box::<i8>) {
debug dummy => const 20_i8;
}
bb0: {
_1 = std::intrinsics::black_box::<i8>(const 20_i8) -> [return: bb4, unwind unreachable];
}
bb1: {
StorageDead(_3);
_1 = Add(copy _1, const 1_i8);
goto -> bb3;
}
bb2: {
StorageDead(_3);
_1 = Sub(copy _1, const 1_i8);
goto -> bb3;
}
bb3: {
StorageDead(_2);
StorageLive(_4);
_4 = copy _1;
_1 = Neg(move _4);
StorageDead(_4);
StorageLive(_5);
_5 = std::intrinsics::black_box::<i8>(move _1) -> [return: bb5, unwind unreachable];
}
bb4: {
StorageLive(_2);
StorageLive(_3);
_3 = copy _1;
_2 = Lt(move _3, const 5_i8);
switchInt(move _2) -> [0: bb2, otherwise: bb1];
}
bb5: {
StorageDead(_5);
return;
}
}
Then run it with the counter backend. For this input, the output is similar to:
total assignment: 7
binary: {Lt: 1, Sub: 1}
unary: {Neg: 1}
Build a Cargo Package with Leaf
As mentioned before, you can look at leafc as a wrapper around rustc and
it should be work in any command using rustc.
Building packages is no exception. As supported by cargo you can set
build.rust configuration key
to override the compiler.
$ export RUSTC=leafc
$ cargo build
Leaf’s Modifications
leafc is slightly specialized when used by cargo for compiling crate’s dependencies.
(TODO)
Diverging Input Generation
Important
This document is currently obsolete and will be removed with further developments of the book. The orchestrators mentioned in this tutorial are currently moved out of the project.
Each instance of concolic execution of a program, records a trace of the constraints put on symbolic variables at each step of the execution. Conditional branches are the major source of these constraints and whether they are held or not determines the target of the branch. Therefore, concolic execution can be used to find concrete values for the symbolic variables such that the execution diverges at conditional branches compared to the previous execution. We refer to these concrete values as diverging inputs, counterexamples, or generally answers.
The current default configuration of Leaf tries to find a diverging input whenever
a new constraint is observed.
For instance, in the following program, the input (x = 10) does not satisfy
the branch condition (x < 5), which the backend reports as {!(<(<Var1: u8>, 5u8))}.
It tries to find an input that would satisfy it (so the execution would diverge at this point),
and reports back value 0u8 as a possible one.
Diverging Standard Input Generation
If all symbolic variables are from u8, the default configuration puts the found answers
in binary files in which each byte corresponds to a symbolic variable ordered by
when they were marked as symbolic.
This is mainly meant for situations where we want to mark a file symbolic, e.g., standard input.
Although the backend (and execution of the instrumented program) is enough to obtain the diverging standard input, the one-time orchestrator is provided to facilitate this process with further control.
You can install it by running the following command in Leaf’s root folder.
leaf$ cargo install --path ./orchestrator
Then you provide the path to the instrumented program and the desired path to put the diverging inputs at. For example,
$ leafo_onetime --program ./hello_world --outdir ./next
next/diverging_0.bin
It runs the target program, and prints the names of the files generated as diverging input.
Pure Concolic Testing
By repeating the above procedure for each generated input, more possible paths in the target program will be covered. We use the term pure concolic testing, as used by SymCC for this method of testing.
If you are interested in finding possible crashes in your program using pure concolic testing,
the project comes with a utility named leaff_pure_conc, which runs the loop for programs and captures those that cause a crash
in them. Currently, it only supports programs with symbolic standard input (uses the one-time orchestrator).
To run pure concolic testing loop for a target:
- Install the tool.
leaf$ cargo install --path ./integration/libafl/fuzzers/pure_concolic - Build your program with
leafc. - Pass the executable path to the tool. e.g.,
$ leaff_pure_conc --conc-program ./hello_world - The loop stops if no more new distinct input is found to be given to the program (can possibly run forever).
We recommend looking at the options available for tuning the loop by passing --help.
Fuzzing
Important
This document is currently obsolete and will be removed with further developments of the book. The orchestrators mentioned in this tutorial are currently moved out of the project.
One of the use cases of concolic execution, which is demonstrated to be effective, is hybrid fuzzing, in which fuzzing is aided with solver-found inputs generated by symbolic execution to take certain paths inside the program that other techniques are inefficient to find.
Leaf is aimed to be suitable for this purpose and comes with a built-in support for LibAFL,
a customizable fuzzing framework with modern architecture written in Rust.
Crate libafl_leaf contains facilities for using Leaf-instrumented programs with
the fuzzers written using this library.
Hybrid Fuzzing for libFuzzer
In an abstract manner, hybrid fuzzing for LibAFL-based fuzzers is achievable using a stage that generates diverging inputs from the current test case. This stage should perform the concolic execution using the current test case to derive the diverging inputs and offer them to the fuzzer for evaluation. Thus, the following steps are presumable for an execution-based concolic executor like Leaf.
- Build an executable equivalent to the fuzz target, which is suitable for concolic execution.
- Define a mutator stage that runs the built executable and obtains new inputs.
- Add the stage to the fuzzer.
The mentioned ingredients are provided by Leaf;
leafc instruments your target program,
leafo_onetime helps with collecting the diverging inputs,
and libafl_leaf provides the stage.
As libFuzzer (through cargo-fuzz) is one the most-used tools to perform fuzzing
for Rust projects, a rudimentary support is also provided for harnesses
written based on libfuzzer-sys
to upgrade them to a hybrid fuzzer.
It is developed as an extension of LibAFL’s implementation of libFuzzer, so
the same instructions and options
apply.
Recipe
With an understanding of the general procedure above, you can follow the instruction below to upgrade your existing fuzzer to a hybrid one.
- Prerequisites
-
The one-time orchestrator (
leafo_onetime) is installed in your environment. If not, install it similarly toleafcusing the following command in Leaf’s root folder.leaf$ cargo install --path ./orchestrator -
You have a fuzzer written using
libfuzzer-sys. We assume it is namedfuzz_target_1and has the following template.#![no_main] use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { // fuzzed code goes here });
-
-
Replace
libfuzzer-syssource to Leaf’s implementation inCargo.tomlof your fuzz project.# From libfuzzer-sys = { version = "...", features = ["your", "features", "here"] } # To libfuzzer-sys = { git = "https://github.com/sfu-rsl/leaf.git", package = "libafl_libfuzzer", features = ["your", "features", "here"]} -
Change
fuzz_targetmacro invocation tohybrid_fuzz_target, and makeno_mainattribute conditional based on compilation withleafc.#![cfg_attr(not(leafc), no_main)] use libfuzzer_sys::hybrid_fuzz_target; hybrid_fuzz_target!(|data: &[u8]| { // fuzzed code goes here });(
hybrid_fuzz_targetadditionally writes a program with amainfunction that reads the whole standard input, marks it as symbolic, and passes to the closure.) -
Build your fuzz target with
leafcin a separate cargo target directory like below.fuzz$ RUSTC=leafc cargo build --bin fuzz_target_1 --target-dir ./target/leaf -
Build your fuzzer normally, e.g.,
fuzz$ cargo fuzz build fuzz_target_1 -
Run your fuzzer with the additional argument
conc_programwhich points to the instrumented executable built usingleafc.fuzz$ cargo fuzz run fuzz_target_1 -- -conc_program=./target/leaf/debug/fuzz_target_1
Please refer to the technical documentation for further details about the components and steps mentioned above.
How Leaf Builds a Compatible Compiler Toolchain1
Leaf instruments MIR from the program being analyzed. For that instrumentation
to work consistently, the compiler must also be able to emit and use MIR for
the Rust libraries that the program depends on, including core and std.
The standard libraries supplied by rustup are compiled for ordinary Rust
compilation; they are not necessarily compiled with the options Leaf needs.
leafc therefore builds a separate, Leaf-compatible sysroot when the current
one is not already suitable.
When and why Leaf builds a sysroot
For each original Rust toolchain, Leaf builds one compatible toolchain when a
previously built compatible toolchain is not already available. Codegen-all-MIR
mode is enabled by default, and in this mode leafc checks the
compiler’s current sysroot during compiler configuration. The flow is:
- If the current sysroot contains
.leafc_toolchain, it is treated as a Leaf-built sysroot and used as-is. - Otherwise,
leafclooks through the persistent generated-toolchain cache for an artifact whose marker identifies the same original sysroot. - If no compatible artifact is found,
leafcinvokes the toolchain builder. - The resulting path is installed as the compiler’s sysroot.
How the build works
In essence, the builder rebuilds the Rust sysroot and copies the build results into a separate directory. The generated toolchain is then selected automatically for the compilation that needs it.
Tip
If a cached toolchain produces unexpected errors, stop any Leaf compilation and remove the generated toolchain directory, or remove the relevant child directory inside it. The next compilation will rebuild the sysroot from the current original sysroot. Removing this cache does not remove or modify the original rustup toolchain.
Runtime shim
The runtime shim is needed by instrumented code and is included in the compatible toolchain automatically. Users normally do not need to choose where it is built or loaded.
Important paths
leafc_toolchains: the persistent cache beside theleafcexecutable..leafc_toolchain: a marker in a generated toolchain that records the path to the original sysroot used to build it.toolchain_builder/build: the builder script invoked byleafc.scripts/toolchain_builder/res/crate_template: templates for the temporary dummy crate and its Cargo configuration.leafc/toolchain_builder/<id>: the temporary work directory when the crate output directory is used; otherwise the work directory is under the system temporary directory.
For contributors
The original and generated sysroots
It is useful to distinguish two paths:
- The original sysroot is the Rust toolchain selected by rustup or supplied to the compiler. It provides the Rust sources and tools from which the build starts.
- The generated Leaf sysroot is a separate directory containing the
libraries rebuilt for Leaf.
leafcinstalls this path as the sysroot for the compilation that requested it.
The generated directory is not the original rustup installation modified in place. This keeps the user’s toolchain reusable and gives Leaf a clear place to cache its artifacts.
The compatibility check canonicalizes both paths before comparing them. This means that the marker is not a general description of the generated toolchain: it stores the path to the original sysroot from which that generated toolchain was built. The marker lets a later invocation recognize that relationship.
This check is skipped while the builder is compiling the standard library.
That exception is controlled by LEAFC_BUILDING_CORE, which becomes the
building_core compiler configuration flag. Without this recursion guard,
the leafc used by Cargo to build core could try to build or select another
Leaf sysroot while the current one was still being assembled.
Build details
The Rust side locates toolchain_builder/build, creates a fresh work directory,
and starts the Python script with the original sysroot and output paths. A work
directory is placed below leafc/toolchain_builder under the crate output
directory when one is available; otherwise it is placed under the system
temporary directory. The output directory is created beside the leafc
executable under leafc_toolchains.
Cargo’s unstable -Zbuild-std support builds the standard library as part of a
crate build. The Python builder creates a small dummy crate from its templates,
then uses Cargo to:
- ask Cargo for the sysroot selected for the build;
- build
core,std, panic support, and test support with-Zbuild-std=core,std,panic_unwind,panic_abort,test; and - collect the resulting libraries from the dummy crate’s target directory.
The dummy crate gives Cargo the dependency graph and build context it needs;
it is not part of the generated sysroot. The builder copies the resulting
lib*.r* files, excluding the dummy crate’s own library, into the
target-specific Rust library directory.
The builder uses the release profile.
Runtime shim placement
The runtime shim is needed by instrumented code. The current default is the
external-dependency mode: the dummy crate depends on the runtime_shim
package, and the generated build places the shim alongside the other built
dependencies. The builder checks the dummy crate’s dependency tree in this
mode so that unexpected transitive dependencies do not silently become part
of the sysroot.
There is also an internal core-patching mode. In that mode the builder
copies the original toolchain to its work area, copies Leaf’s common and
runtime-shim sources into the Rust core sources, and applies Leaf’s core
patches before building. The templates select core_build and the in-core shim
location for this path. This mode is deprecated and is not planned to be
maintained; it remains relevant when interpreting existing configuration or
diagnosing an older build.
The leafc process used for this Cargo build must understand that it is
building the standard library. The LEAFC_BUILDING_CORE setting is the
recursion guard that separates this bootstrap work from normal application
compilation.
Assembly and caching
After Cargo succeeds, the Python builder creates an output directory with the
sysroot library layout and copies the built libraries into it. It then writes
.leafc_toolchain containing the original sysroot path. Finally, the Rust
side prints the output path, removes the temporary work directory, and tries
to move the artifact into the persistent leafc_toolchains directory beside
leafc.
The persistent directory name is derived from the libcore artifact found
under the requested target’s library directory. The target triple is used to
find that artifact, and cache lookup also requires the matching
lib/rustlib/<target>/lib directory before checking the original-sysroot
marker. This prevents a cached sysroot for one architecture from being reused
for another.
If two compiler processes build the same artifact concurrently, an existing persistent destination wins when the Rust side reaches it first. If persistence fails, the freshly generated output can still be returned for the current compilation, with a warning logged.
Diagnosing failures
The failure usually belongs to one of a few boundaries:
- Builder discovery or process startup:
leafccould not find or execute the builder. Check the compiler log and the resolved builder location. - Rust source availability: the original toolchain must include the Rust
source component, because the builder needs
coresources. A missing source directory is reported before the core build begins. - Cargo and toolchain selection:
-Zbuild-stddepends on the nightly toolchain and its Cargo support. Inspect the builder log for the exact Cargo command and selected sysroot. - Shim configuration: external mode expects the shim dependency in the configured dependency location, while internal mode expects the copied core sources and patches to apply cleanly.
- Artifact assembly: a missing
libcorefile, an unexpected target/profile directory, or an incomplete Cargo build prevents the output from becoming a usable cached artifact. - Cache compatibility: a generated artifact is associated with the
original path recorded in
.leafc_toolchain. Changing or removing that toolchain, or relying on a path that no longer canonicalizes, can make a previously cached artifact unusable.
For environment variables, logging controls, manual invocation details, and the names of the builder inputs, see the toolchain builder README.
-
This page was created with AI assistance under the supervision and review of the maintainers. ↩
Configuration
Leaf’s configuration is organized by the component it controls. The compiler
configuration reference describes leafc settings, including pass selection,
instrumentation rules, runtime-shim configuration, and configuration loading.
Compiler Configuration1
This page describes the configuration surface of leafc: where configuration
comes from, which compiler passes run, and how instrumentation rules select MIR
entities. It also documents the settings behind Leaf’s standard compiler setup
for codegen, sysroot compatibility, and the runtime shim.
Pass configuration
Each configurable pass is configured in its own passes.<pass_name> table. At
minimum, every such table has an enabled flag, which defaults to true and
enables or disables that pass. Some pass tables have additional fields;
instrumentation and internalization provide a rules field.
| Pass | Additional configuration |
|---|---|
instrumentation | rules selects MIR entities for instrumentation. |
instrumentation_counter | none |
instrumentation_rec_check | none |
internalization | rules selects functions for internalization. |
program_map | none |
program_dep | none |
type_export | none |
md_info | none |
For example, a pass can be disabled as follows and it won’t run during compilation:
[passes.instrumentation]
enabled = false
Instrumentation rules
Each rule is an include or exclude entry with an entity tag. The
available entities and aliases are:
include and exclude are parallel lists. If any include rule matches an
entity, it is included and takes precedence over every matching exclude rule.
Otherwise, a matching exclude rule excludes it; if neither list matches, the
rules provide no decision and the pass’s normal behavior is retained.
Canonical entity | Aliases | Entity-specific field |
|---|---|---|
whole_body | body | none |
method_dyn_definition | dyn_def | none |
place_info | none | kind, then piece for structure |
operand_kind | none | kind |
constant_type | const_type | ty or alias type |
assignment | none | kind |
assignment_info | none | kind |
storage_lifetime_marker | storage_lifetime, lifetime_marker | kind |
call_flow | none | kind |
drop | none | kind |
switch | none | kind |
Every instrumentation filter has two parts: criteria for the selected entity,
such as kind or piece, and location criteria named loc that describe the
location of the function MIR body being instrumented. loc is a field in the configuration
schema, but it is flattened when reading TOML, so its predicates appear next to
the other criteria rather than under a literal loc key. The conceptual shape
is:
entity-specific criteria + loc: location expression
The TOML form is:
[[passes.instrumentation.rules.exclude]]
entity = "assignment"
kind = "binary_op"
crate = { name = "my_app" }
Here, kind is the entity-specific criterion and crate is part of loc.
The accepted location values are:
| Location field | Values | Description |
|---|---|---|
crate.name | regex string | The name of the parent crate of the body. |
crate.is_external | true or false | Whether the parent crate of the body is external (a dependency) |
def_path (alias def_path_match) | regex string | MIR’s DefPath of the body. |
def_id | "crate-number:index" | MIR’s DefId of the function body. |
The accepted entity-specific values are:
| Entity kind | Field | Values | Expressions |
|---|---|---|---|
place_info | kind | structure (alias struct), address (alias addr), type (alias ty) | no |
place_info | piece | local, deref, field, index, constant_index, subslice, downcast, opaque_cast, unwrap_unsafe_binder | yes |
operand_kind | kind | copy, move, constant (alias const) | yes |
constant_type | ty | bool, char, int, float, str, byte_str, ptr, zst | yes |
assignment / assignment_info | kind | use, repeat, ref, thread_local_ref, raw_ptr, cast, binary_op, unary_op, discriminant, aggregate, wrap_unsafe_binder, intrinsic_unary_op, intrinsic_binary_op, intrinsic_ternary_op, intrinsic_misc_op, intrinsic_memory_op, atomic_binary_op, atomic_memory_op | yes |
storage_lifetime_marker | kind | live, dead | yes |
call_flow | kind | call_control, call_address (alias call_addr), call_input, function_address (alias func_addr), function_data (alias func_data) | yes |
drop | kind | call_control, call_address (alias call_addr), call_input | yes |
switch | kind | control, data | yes |
Writing rule expressions
Where supported, a field can be a boolean expression rather than a single
value. The tables above mark the fields that support expressions. An atomic
value is a string, enum value, boolean, or nested table accepted by the field.
An expression can also be a table with a single field of not, any, or
all:
[[passes.instrumentation.rules.exclude]]
entity = "operand_kind"
kind = { any = ["move", "copy"] }
all = [
{ crate = { is_external = true } },
{ not = { crate = { name = "std" } } },
]
any is true when at least one child is true; all requires every child;
not negates one child. An empty formula table, {}, is a valid empty formula
and evaluates false. This is useful when a rule intentionally supplies no
location restriction, as in all = []: the all formula itself is true, but
an empty atomic formula {} is false. Keep the distinction explicit in
configuration.
Invalid regex syntax is an error when the rules are converted for use.
It’s worth mentioning that two top-level aliases, instr_rules and rules, are available for
passes.instrumentation.rules. While we do not guarantee stability over this shortcut, it is important that you do not provide configurations in both locations as it is an error.
Advanced settings
Leaf has internal procedures for instrumenting the standard library and linking the runtime shim. The defaults below are the recommended settings for that workflow. Customize them when you need to control how Leaf builds or selects a compatible toolchain, uses a different runtime shim, or changes codegen behavior.
Compiler marker
marker_cfg_name defaults to "leafc" and adds that cfg to the crate it compiles. Set it
to another valid cfg name to use a different marker, or to "" to omit the
marker.
For example, the default marker lets a sample select code that is compiled
only by leafc:
#[cfg(leafc)]
let x: u8 = {
use leaf::annotations::*;
x.mark_symbolic()
};
Runtime shim
The default is equivalent to:
[runtime_shim.location.external]
crate_name = "leaf"
search_path = "sysroot"
Use the core library as the shim with either spelling:
[runtime_shim]
location = "core_lib" # `core` is an alias
For an external shim, crate_name defaults to "leaf". search_path accepts:
| Value | Meaning |
|---|---|
"sysroot" or "default" | Find the crate through the normal library search paths. This is the default and is intended for Leaf-compatible sysroots. |
"crate_deps" or "deps" | Treat the shim as a normal dependency found in the crate’s sysroot or supplied search paths. |
"compiler" | Use Leaf’s compiler-side runtime_shim/libleafrtsh.rlib and its adjacent deps directory. |
{ exact = "..." } or { exact = "/absolute/path/to/libleafrtsh.rlib" } | Use an exact runtime-shim library path and its adjacent dependency directory. "exact" is also the enum alias, but the value must be a path string. |
For example:
[runtime_shim.location.external]
crate_name = "my_leaf_shim"
search_path = "deps"
The external form adds the configured crate as a forced prelude dependency;
the core-library form expects the shim to be part of core and adds no
external dependency.
Sysroot and codegen
| Key | Default | Effect |
|---|---|---|
codegen_all_mir | true | Enables MIR/codegen for all items, i.e., including items from upstream dependencies. It also forces one codegen unit, disables LTO when necessary, and uses unwind panic strategy when required. |
override_sysroot | true | Allows codegen-all mode to find or build a Leaf-compatible sysroot when the current sysroot is incompatible. |
building_core | false | Identifies a core-library build. Used when building a compatible toolchain. See How Leaf Builds a Compatible Compiler Toolchain. |
codegen_all_mir and override_sysroot are usually left at their defaults.
codegen_all_mir = false selects the non-codegen-all pipeline. However,
runtime_shim.location = "external" with search_path = "sysroot" requires
codegen-all mode and enables it with a warning. When codegen-all mode is on,
Leaf may override the current sysroot unless override_sysroot is false.
Disabling that override is intended for special build scenarios and can cause
errors if the sysroot does not contain MIR for all required libraries.
Selecting functions for internalization
The internalization pass is configured under passes.internalization. Its
enabled flag controls the pass, and its rules select which generated
functions are made internal. Each include or exclude entry matches a generated
function’s symbol name with a regex and can use the same boolean expression
forms as instrumentation.
[passes.internalization.rules]
include = [
{ any = ["my_crate::public_api::.*", "leafrtsh::.*"] },
]
exclude = [
{ not = "my_crate::keep_exported" },
]
The same not, any, all, empty-formula, and regex rules apply. A matching
include selects the symbol for internalization. An unmatched symbol follows
the compiler’s default of internalizing it unless it is a required public C
export, weak lang item, Rust compiler symbol, or proc-macro export.
Loading and precedence
leafc relies on the config to load configurations. The sources are applied in this order:
- schema defaults,
leafc_config.<ext>file: searched in the current working directory, the compiler executable’s directory, including their ancestors in order,- an inline configuration string, and
LEAFCenvironment variables.
Later sources override earlier values.
Inline configuration
An inline configuration is enabled only when both variables are set:
export LEAFC_CONFIG_STR=$'[passes.instrumentation]\nenabled = false'
export LEAFC_CONFIG_STR_FMT=toml
The format name may be a supported file extension: toml, json, json5,
yaml, ron, or ini. An unknown format is ignored with a warning.
Environment variables
LEAFC is the environment-variable prefix used by leafc. The loader uses
_ between the prefix and a top-level key, and __ for nested keys. For
example:
export LEAFC_PASSES__INSTRUMENTATION__ENABLED=false
export LEAFC_MARKER_CFG_NAME=my_leafc_marker
The file and inline string use the config crate’s normal source merging, so
the names and value types must match the schema above.
Example
codegen_all_mir = true
override_sysroot = true
marker_cfg_name = "leafc"
[runtime_shim.location.external]
crate_name = "leaf"
search_path = "sysroot"
[passes.instrumentation]
enabled = true
[[passes.instrumentation.rules.exclude]]
entity = "whole_body"
crate = { is_external = true }
[[passes.instrumentation.rules.include]]
entity = "assignment"
kind = { any = ["binary_op", "atomic_binary_op"] }
crate = { name = "my_app" }
[passes.internalization]
enabled = true
[passes.internalization.rules]
exclude = ["LLVMFuzzerInitialize"]
[passes.md_info]
enabled = false
Troubleshooting
Failed to read configurations: check TOML syntax, environment value types, table nesting, and inline format. An inline string requires bothLEAFC_CONFIG_STRandLEAFC_CONFIG_STR_FMT.- Unknown
search_pathvalue: usesysroot/default,crate_deps/deps,compiler, or{ exact = "..." }. The external form also needs acrate_nameif it is notleaf. - Runtime shim not found: use
core_libonly when the shim is in the core library; otherwise verify the selected library path and its adjacentdepsdirectory. - Sysroot or MIR errors:
sysrootshim lookup and codegen-all mode are coupled. Use a Leaf-compatible sysroot, leaveoverride_sysroot = true, or choose another shim location for a special non-codegen-all build. - Rules appear ignored: use
passes.instrumentation.rulesfor new files, and do not populate it together with non-empty top-levelinstr_rulesorrules. - A rule fails to parse: verify the entity tag, its field (
tyversuskind), aliases, enum spelling, and formula shape.def_idmust be acrate-number:indexstring and pattern values must be valid regexes.
Implementation references
common/src/config.rs: file discovery, source precedence, inline formats, and environment naming.compiler/src/config.rs: compiler schema, defaults, runtime-shim values, pass gates, formulas, and legacy-rule migration.compiler/src/passes/main/instr/config/mod.rs: instrumentation entities, aliases, fields, and enum values.compiler/src/passes/main/internalizer.rs: internalization rule shape and behavior.
-
This page was created with AI assistance under the supervision and review of the maintainers. ↩
Samples Reference
Map
Here is a map of sample programs put under samples in the work tree of the project.
| Sample | Targets |
|---|---|
assignment/addr_of | Rvalue::RawPtr |
assignment/aggregate | Rvalue::Aggregate |
assignment/bin_op | Rvalue::BinaryOp |
const | Operand::Constant::* |
assignment/discr | Rvalue::Discriminant |
assignment/ref | Rvalue::Ref |
assignment/repeat_array | Rvalue::Repeat |
assignment/set_discr | StatementKind::SetDiscriminant |
assignment/thread_local_ref | Rvalue::ThreadLocalRef |
assignment/un_op | Rvalue::UnaryOp |
branching/assert | TerminatorKind::Assert |
branching/if_basic | TerminatorKind::SwitchInt, if |
branching/if_else | TerminatorKind::SwitchInt, if, else if, else |
branching/if_let | TerminatorKind::SwitchInt, if let |
branching/match_basic | TerminatorKind::SwitchInt, match |
branching/match_enum | TerminatorKind::SwitchInt, Rvalue::Discriminant, match <enum> |
casting/numeric | Rvalue::Cast, CastKind::IntTo*, CastKind::FloatTo* |
casting/pointer | Rvalue::Cast, CastKind::PtrToPtr, CastKind::PointerCoercion, PointerCoercion::* |
casting/subtype | Rvalue::Cast, CastKind::Subtype |
casting/transmute | Rvalue::Cast, CastKind::Transmute |
drop | TerminatorKind::Drop, intrinsics::drop_glue |
function/async | Async Functions, TyKind::CoroutineClosure |
function/call_basic | TerminatorKind::Call |
function/closures | TyKind::Closure, Fn* traits, tupling/untupling arguments |
function/coroutines | TyKind::Coroutine |
function/shims | ShimKind |
intrinsics/atomic | intrinsics::atomic_* |
intrinsics/memory | (Raw) Memory-related intrinsics |
intrinsics/operators | Intrinsic (arithmetic) operators |
misc/intrinsics | Misc intrinsic usage |
misc/leaf_attr | Using Leaf-specific attributes, #[leaf_attr::instrument] |
misc/no_diverge | Pushing/popping tags |
misc/promoted | Promoted bodies |
misc/static | Static items and accesses |
place/deref_mut | Dereferencing mutable references |
place/projection/downcast | PlaceElem::Downcast |
place/projection/field | PlaceElem::Field |
place/projection/index | PlaceElem::Index |
place/projection/unwrap_unsafe_binder | PlaceElem::UnwrapUnsafeBinder |
sym_place/read | #SymEx Reading symbolic places |
sym_place/write | #SymEx Writing to symbolic places |
function/sym_* | #SymEx Symbolic values transferred between functions |
basic | Basic algorithm implementations |
crates/multi_file_bin | Multi-file crate compilation |
crates/single_file_bin | Single-file crate baseline |
crates/with_dep | Crate with dependencies |
crates/with_shared_dep | Crate with shared transitive dependency |