Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Leaf

The Leaf logo

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:

  1. write or choose a Rust program,
  2. compile it with Leaf’s instrumentation pipeline,
  3. run the instrumented binary with the desired backend, and
  4. 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:

  1. compile and instrument a target program with leafc, and
  2. provide a runtime backend that receives callbacks from the instrumented program,
  3. run the instrumented program with the backend plugged in.

Requirements

  • Rust
  • Python
  • A working C toolchain and linker

Installing Leaf

  1. Clone the repository and enter the workspace.

    $ git clone https://github.com/sfu-rsl/leaf.git
    $ cd leaf
    
  2. Install the compiler frontend.

    $ cargo install --path ./compiler
    

Preparing Dynamic Analysis

  1. Build a runtime backend, for example symbolic execution.

    $ cargo build -p runtime_symex
    
  2. 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!");
    }
}
  1. Compile the sample with leafc.

    $ leafc samples/hello_world.rs
    
  2. Enable logging for the runtime.

    $ export LEAF_LOG="info"
    
  3. 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:

  1. Define a minimal RuntimeBackend.
  2. Define an AssignmentHandler that performs counting.
  3. Add an InstanceManager for the backend.
  4. Package the backend as a dynamic library.
  5. 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_assignments for 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 of assign_to(ID_X, p_dest).unary_op_on(UnaryOp::Neg, p_operand) is expected where ID_X corresponds to the unique id for this assignment in its parent body, p_dest and p_operand correspond to place representations for locals _5 and _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:

  1. Install the tool.
    leaf$ cargo install --path ./integration/libafl/fuzzers/pure_concolic
    
  2. Build your program with leafc.
  3. Pass the executable path to the tool. e.g.,
    $ leaff_pure_conc --conc-program ./hello_world
    
  4. 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.

  1. Build an executable equivalent to the fuzz target, which is suitable for concolic execution.
  2. Define a mutator stage that runs the built executable and obtains new inputs.
  3. 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
    1. The one-time orchestrator (leafo_onetime) is installed in your environment. If not, install it similarly to leafc using the following command in Leaf’s root folder.

      leaf$ cargo install --path ./orchestrator
      
    2. You have a fuzzer written using libfuzzer-sys. We assume it is named fuzz_target_1 and has the following template.

      #![no_main]
      
      use libfuzzer_sys::fuzz_target;
      
      fuzz_target!(|data: &[u8]| {
          // fuzzed code goes here
      });
  1. Replace libfuzzer-sys source to Leaf’s implementation in Cargo.toml of 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"]}
    
  2. Change fuzz_target macro invocation to hybrid_fuzz_target, and make no_main attribute conditional based on compilation with leafc.

    #![cfg_attr(not(leafc), no_main)]
    
    use libfuzzer_sys::hybrid_fuzz_target;
    
    hybrid_fuzz_target!(|data: &[u8]| {
        // fuzzed code goes here
    });

    (hybrid_fuzz_target additionally writes a program with a main function that reads the whole standard input, marks it as symbolic, and passes to the closure.)

  3. Build your fuzz target with leafc in a separate cargo target directory like below.

    fuzz$ RUSTC=leafc cargo build --bin fuzz_target_1 --target-dir ./target/leaf
    
  4. Build your fuzzer normally, e.g.,

    fuzz$ cargo fuzz build fuzz_target_1
    
  5. Run your fuzzer with the additional argument conc_program which points to the instrumented executable built using leafc.

    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:

  1. If the current sysroot contains .leafc_toolchain, it is treated as a Leaf-built sysroot and used as-is.
  2. Otherwise, leafc looks through the persistent generated-toolchain cache for an artifact whose marker identifies the same original sysroot.
  3. If no compatible artifact is found, leafc invokes the toolchain builder.
  4. 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 the leafc executable.
  • .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 by leafc.
  • 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. leafc installs 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:

  1. ask Cargo for the sysroot selected for the build;
  2. build core, std, panic support, and test support with -Zbuild-std=core,std,panic_unwind,panic_abort,test; and
  3. 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: leafc could 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 core sources. A missing source directory is reported before the core build begins.
  • Cargo and toolchain selection: -Zbuild-std depends 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 libcore file, 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.


  1. 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.

PassAdditional configuration
instrumentationrules selects MIR entities for instrumentation.
instrumentation_counternone
instrumentation_rec_checknone
internalizationrules selects functions for internalization.
program_mapnone
program_depnone
type_exportnone
md_infonone

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 entityAliasesEntity-specific field
whole_bodybodynone
method_dyn_definitiondyn_defnone
place_infononekind, then piece for structure
operand_kindnonekind
constant_typeconst_typety or alias type
assignmentnonekind
assignment_infononekind
storage_lifetime_markerstorage_lifetime, lifetime_markerkind
call_flownonekind
dropnonekind
switchnonekind

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 fieldValuesDescription
crate.nameregex stringThe name of the parent crate of the body.
crate.is_externaltrue or falseWhether the parent crate of the body is external (a dependency)
def_path (alias def_path_match)regex stringMIR’s DefPath of the body.
def_id"crate-number:index"MIR’s DefId of the function body.

The accepted entity-specific values are:

Entity kindFieldValuesExpressions
place_infokindstructure (alias struct), address (alias addr), type (alias ty)no
place_infopiecelocal, deref, field, index, constant_index, subslice, downcast, opaque_cast, unwrap_unsafe_binderyes
operand_kindkindcopy, move, constant (alias const)yes
constant_typetybool, char, int, float, str, byte_str, ptr, zstyes
assignment / assignment_infokinduse, 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_opyes
storage_lifetime_markerkindlive, deadyes
call_flowkindcall_control, call_address (alias call_addr), call_input, function_address (alias func_addr), function_data (alias func_data)yes
dropkindcall_control, call_address (alias call_addr), call_inputyes
switchkindcontrol, datayes

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:

ValueMeaning
"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

KeyDefaultEffect
codegen_all_mirtrueEnables 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_sysroottrueAllows codegen-all mode to find or build a Leaf-compatible sysroot when the current sysroot is incompatible.
building_corefalseIdentifies 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:

  1. schema defaults,
  2. leafc_config.<ext> file: searched in the current working directory, the compiler executable’s directory, including their ancestors in order,
  3. an inline configuration string, and
  4. LEAFC environment 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 both LEAFC_CONFIG_STR and LEAFC_CONFIG_STR_FMT.
  • Unknown search_path value: use sysroot/default, crate_deps/deps, compiler, or { exact = "..." }. The external form also needs a crate_name if it is not leaf.
  • Runtime shim not found: use core_lib only when the shim is in the core library; otherwise verify the selected library path and its adjacent deps directory.
  • Sysroot or MIR errors: sysroot shim lookup and codegen-all mode are coupled. Use a Leaf-compatible sysroot, leave override_sysroot = true, or choose another shim location for a special non-codegen-all build.
  • Rules appear ignored: use passes.instrumentation.rules for new files, and do not populate it together with non-empty top-level instr_rules or rules.
  • A rule fails to parse: verify the entity tag, its field (ty versus kind), aliases, enum spelling, and formula shape. def_id must be a crate-number:index string and pattern values must be valid regexes.

Implementation references


  1. 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.

SampleTargets
assignment/addr_ofRvalue::RawPtr
assignment/aggregateRvalue::Aggregate
assignment/bin_opRvalue::BinaryOp
constOperand::Constant::*
assignment/discrRvalue::Discriminant
assignment/refRvalue::Ref
assignment/repeat_arrayRvalue::Repeat
assignment/set_discrStatementKind::SetDiscriminant
assignment/thread_local_refRvalue::ThreadLocalRef
assignment/un_opRvalue::UnaryOp
branching/assertTerminatorKind::Assert
branching/if_basicTerminatorKind::SwitchInt, if
branching/if_elseTerminatorKind::SwitchInt, if, else if, else
branching/if_letTerminatorKind::SwitchInt, if let
branching/match_basicTerminatorKind::SwitchInt, match
branching/match_enumTerminatorKind::SwitchInt, Rvalue::Discriminant, match <enum>
casting/numericRvalue::Cast, CastKind::IntTo*, CastKind::FloatTo*
casting/pointerRvalue::Cast, CastKind::PtrToPtr, CastKind::PointerCoercion, PointerCoercion::*
casting/subtypeRvalue::Cast, CastKind::Subtype
casting/transmuteRvalue::Cast, CastKind::Transmute
dropTerminatorKind::Drop, intrinsics::drop_glue
function/asyncAsync Functions, TyKind::CoroutineClosure
function/call_basicTerminatorKind::Call
function/closuresTyKind::Closure, Fn* traits, tupling/untupling arguments
function/coroutinesTyKind::Coroutine
function/shimsShimKind
intrinsics/atomicintrinsics::atomic_*
intrinsics/memory(Raw) Memory-related intrinsics
intrinsics/operatorsIntrinsic (arithmetic) operators
misc/intrinsicsMisc intrinsic usage
misc/leaf_attrUsing Leaf-specific attributes, #[leaf_attr::instrument]
misc/no_divergePushing/popping tags
misc/promotedPromoted bodies
misc/staticStatic items and accesses
place/deref_mutDereferencing mutable references
place/projection/downcastPlaceElem::Downcast
place/projection/fieldPlaceElem::Field
place/projection/indexPlaceElem::Index
place/projection/unwrap_unsafe_binderPlaceElem::UnwrapUnsafeBinder
sym_place/read#SymEx Reading symbolic places
sym_place/write#SymEx Writing to symbolic places
function/sym_*#SymEx Symbolic values transferred between functions
basicBasic algorithm implementations
crates/multi_file_binMulti-file crate compilation
crates/single_file_binSingle-file crate baseline
crates/with_depCrate with dependencies
crates/with_shared_depCrate with shared transitive dependency