Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
Analyze & explore related research in Conceptio
Connect this record directly into your research workflow or AI agent. Explore citations across open repositories, query full text, and export structured proof bundles.
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
arXiv:2611.10394v1 [cs.PL] 7 Nov 2026
MARIUS MOMEU, University of California, Berkeley, USA MARIUS ARVINTE, Intel Corporation, USA CORY CORNELIUS, Intel Corporation, USA KOUSHIK SEN, University of California, Berkeley, USA Wrapping C code in a safe Rust interface lets Rust applications use it without writing unsafe code, until the code itself is migrated to Rust. Such bindings are tedious and error-prone to write, however: their internals use unsafe code that the Rust compiler does not check, so a single mistake can introduce undefined behavior (UB), and they have so far been written mostly by hand. We present Crustify, an agentic co-translator that generates safe Rust bindings for repository-level C libraries, with or without its user in the loop. Crustify schedules a target’s types and symbols into dependencyordered waves of independent batches that agents bind concurrently. Its agents derive the ownership and lifecycle contract of every pointer and type they wrap, check each binding with coverage-guided equivalence and UB tests under sanitizers and Miri, and review every wave before its dependents start. They build on three tools that we contribute: (a) ffibox, a framework of Rust primitives for C/Rust interop that mirror Box, Arc, and Vec for C objects without ever forming a reference to memory that C may alias; (b) Wavefront, a neuro-symbolic static analyzer that orders a target’s items by their dependencies, including those that casts and type-erased pointers hide; and (c) ffimap, a rustc-based analysis that maps the unsafe surface that bindings expose. On libgit2 and ffmpeg, with one developer in the loop, Crustify generated safe bindings for 41.9% and 42.8% of their public APIs in about 51.9 and 58.9 hours, respectively. On the interface that consumers are expected to use, only 1.6% and 0.4% of the bindings’ public functions are unsafe, and only 0.1% and 0.6% have a raw pointer in their signature. Their tests cover 98.92% and 99.07% of the Rust code and 45.95% and 67.20% of libgit2 and libavutil, the only ffmpeg library wrapped so far, and the reviewers fixed 8 and 13 defects that caused UB reachable from Rust. To our knowledge, this is the first work to automate the generation of safe Rust bindings for C libraries. Additional Key Words and Phrases: Rust, foreign function interface, LLM agents, coverage-guided testing, sanitizers, C-to-Rust, safe Rust wrappers
1
Introduction
Translating C code to safe, production-ready Rust is time-consuming and costly. Until a library is translated, Rust applications can only use it through unsafe foreign function interface (FFI) calls and raw pointers, and thus lose Rust’s memory-safety guarantees. An intermediate step is to wrap the C code in a safe Rust binding whose public interface is safe, while its implementation calls the C code through the FFI in unsafe blocks. Rust’s standard library follows the same design: types such as Cell, RefCell, Mutex, and Arc expose a safe interface over an implementation that uses unsafe code [30]. A safe binding should be both sound, i.e., no program that uses it without writing unsafe code can cause undefined behavior (UB), and ergonomic, i.e., its consumers need not uphold any memory-safety discipline themselves: they write no unsafe code, handle no raw pointers, release no C objects by hand, and need not remember to avoid forming references to memory that C may alias, but use the idioms they know from Rust’s standard library. An ergonomic binding is thus also safe to use, since its consumers write no unsafe code; we call the artifacts safe bindings throughout, and use ergonomic for the property that their design targets. Designing such Authors’ Contact Information: Marius Momeu, University of California, Berkeley, USA, marius.momeu@berkeley.edu; Marius Arvinte, Intel Corporation, USA, marius.arvinte@intel.com; Cory Cornelius, Intel Corporation, USA, cory.cornelius@ intel.com; Koushik Sen, University of California, Berkeley, USA, ksen@berkeley.edu.
2
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen
an interface is non-trivial, however, since the Rust compiler does not verify its soundness—the implementer must ensure it. Safe bindings have so far been written mostly by hand. Automated tools either emit raw, unsafe bindings, e.g., bindgen [51], or translate C code to Rust instead of generating safe bindings for it, e.g., C2Rust and its successors [7, 18, 75] and translators based on large language models (LLMs) [8, 14, 24, 28, 45, 55, 62, 69, 74, 77]. Existing open-source projects that publish safe bindings over widely used C libraries took years to develop and still lack complete coverage of the original API [15, 20–22, 50, 53]. Moreover, no widely adopted framework of primitives exists for representing types and pointers that cross the FFI boundary in safe Rust, so most projects write their own variants. Such hand-written encapsulations are prone to UB: McCormack et al. [43] found 46 instances of undefined or undesired behavior in 37 Rust libraries that call foreign functions. In this paper, we present Crustify, an agentic co-translator that generates safe Rust bindings over C APIs, designed to be sound and ergonomic, and scales to repository-level targets. Crustify consists of three tools, which we introduce below, and a common, human-reviewed specification that its LLM agents follow. An assistant agent establishes the campaign’s scope and settings with the user and drives the campaign through the agents it spawns: a planner decomposes the target into modules and schedules their types and symbols into dependency-ordered waves of size-bounded batches; binders bind and test each batch; reviewers audit each landed wave for UB, equivalence divergences from the C implementation, and coverage gaps, and fix the defects they find. The batches of a wave are independent by construction, so Crustify processes them concurrently as isolated agent sessions in separate worktrees. Since every agent’s prompt is assembled from the specification, independent sessions apply the same rules, and a rule changed once changes every agent that follows it (§3.5). To our knowledge, Crustify is the first agentic system that automates the generation of safe Rust bindings for C libraries. We also develop ffibox, a Rust framework of generic primitives that represent C types and pointers that cross the FFI boundary, built around one invariant: no Rust reference to memory that C may alias is ever formed. Policy-typed owners that mirror Box, Arc, and Vec are bound to the C routines that allocate, clone, and free each object, and lifetime-annotated handles encapsulate raw-pointer accesses without forming & or &mut, so that consumers use the idioms of native Rust while the C-specific parts stay internal to the binding. This lets a binding express owned, shared, borrowed, and lock-protected C objects, as well as length-bounded buffers and NUL-terminated strings, while ruling out the aliasing violations that references into C memory cause (§3.1). To our knowledge, ffibox is the first framework of Rust primitives that expresses such pointer-level properties without references into C memory. Wrapping a repository-level target also depends on ordering: a binding can use the safe wrappers of the types it depends on only if they are wrapped first, and otherwise falls back to their raw FFI types and thus to unsafe code. C hides many of these dependencies, since programs emulate polymorphism with casts between struct types and pass objects through type-erased void * slots, and large repositories form dependency cycles. Prior work recovers such casts to check or retype C code [63, 70], whereas project-level C-to-Rust translators order their units by calls and type references only, and merge or reject cycles, or break them wherever their traversal does (§6). We therefore develop Wavefront, a neuro-symbolic static analyzer that extracts with CodeQL the relations the code states unambiguously, such as field and signature types, calls, and direct casts between structs, and lets agents add those that require judgment, such as a void * that passes through several calls before it is cast, in correction tables that it checks for well-formedness. Wavefront performs no data-flow analysis; agents’ judgment covers the relations that data flow would recover. It also breaks the graph’s cycles and schedules it into waves of mutually independent, size-bounded batches, which Crustify runs sequentially within each module, while independent
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
3
modules and the batches of each wave run concurrently (§3.3). To our knowledge, Wavefront is the first to order C-to-Rust work by the relations that casts and type-erased slots hide. Moreover, a binding depends on the ownership and lifecycle contract of every pointer and type that it exposes, i.e., whether a pointer owns or borrows its pointee and which routines construct, release, and clone each type. Prior work infers subsets of these properties, e.g., the ownership of pointers [11, 74, 75], their bounds [44], or allocation and deallocation functions [61]. In Crustify, agents derive these contracts for every pointer and type they bind, store them in Wavefront’s ownership store for later batches and reviewers (§3.3), and validate them through coverage-guided tests under sanitizers (§3.4.3) and through review (§3.4.4). To our knowledge, Crustify is the first system in which LLM agents derive both the ownership and the lifecycle contracts of a C library, including dual ownership, reference-counted cloning, and lock-protected state. Measuring how far a binding falls short of these goals also lacks tooling: existing tools count unsafe code syntactically or by its declared visibility, rather than by what a crate’s consumers can reach, and those that parse unexpanded source miss the unsafe items that macros generate (Appendix C). We therefore develop ffimap, a rustc driver that maps a crate’s FFI surface after type checking. Its generic counters, which apply to any crate, locate the public unsafe functions and the raw pointers on the safe public interface; its ffibox counters measure how much of the C surface a binding wraps with ffibox, and where it bypasses it (§3.2). Agents use ffimap as a linter, and we use it to measure how consistently they built on ffibox rather than reinventing their own primitives, and to compare our bindings with community-maintained ones. Checking the soundness of safe bindings currently lacks a systematic workflow. The three community-maintained bindings that we compare against run their test suites and lints in continuous integration (CI), but no sanitizers, Miri [31], or coverage measurement. Crustify makes these checks part of every batch. Its binders emit equivalence tests that call both the original C function and its Rust wrapper, and guide test generation by line coverage on both sides of the boundary, extending coverage-guided LLM test generation [1, 35] to cross-language targets, whereas C-to-Rust harnesses mostly run existing test suites against the translation [8, 45, 62, 77], or generate equivalence tests without measuring coverage across the boundary [14, 55], and report coverage only as an evaluation metric [6, 74, 77]. Crustify runs these tests under Miri, to detect UB in unsafe Rust, and under AddressSanitizer, UndefinedBehaviorSanitizer, ThreadSanitizer, and BorrowSanitizer [41, 42, 58–60], to detect memory errors, undefined behavior, data races, and aliasing violations across the boundary—a combination that, to our knowledge, no prior C-to-Rust work applies to translated code. We evaluated Crustify on libgit2 and ffmpeg, whose public APIs comprise 165 and 210 types, and 981 and 1,128 symbols, mainly with claude-opus-5.5. In about 51.9 and 58.9 hours of wall-clock time and about 5.1 and 4.5 billion tokens ($2,308 and $2,120), with one developer checking in between its sub-campaigns, Crustify wrapped 41.9% and 42.8% of the two APIs, with tests that cover 98.92% and 99.07% of the Rust bindings and 45.95% and 67.20% of libgit2 and libavutil, the only ffmpeg library wrapped so far, against 60.59% and 45.12% for git2-rs (46.78% for ours over the same C, i.e., libgit2 with its bundled dependencies) and at most 18.73% and 10.08% for the community-maintained ffmpeg bindings (§4.1). On the interface that consumers are expected to use, i.e., excluding the seams that convert between wrappers and C values and the methods that ffibox’s traits declare unsafe, only 1.6% and 0.4% of the bindings’ public functions are unsafe, and only 0.1% and 0.6% have a raw pointer in their signature. As ffimap measures, the bindings build on ffibox rather than on primitives of their own: 66.8% and 66.2% of their public types implement its traits, as layout types and their handles or as lifecycle policies, and 93.2% and 97.7% of the C structs they use behind pointers have such a layout type (§4.2). The review agents found and fixed 8 and 13 defects that caused UB reachable from Rust, each confirmed by a reproducer.
4
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen
In summary, we make the following contributions: • Crustify, a spec-driven agentic co-translator that generates safe Rust bindings for repositorylevel C libraries (§3); • ffibox, Rust primitives for C types and pointers that never form a reference to memory that C may alias (§3.1); • Wavefront, a neuro-symbolic analyzer that schedules a C target into waves of independent batches and stores agents’ ownership contracts (§3.3); • ffimap, a rustc-based analysis of a crate’s unsafe surface and its use of ffibox (§3.2); and • safe bindings for libgit2 and ffmpeg, covering 41.9% and 42.8% of their public APIs (§4). We release the Crustify harness, ffibox, Wavefront, ffimap, and the generated safe bindings for libgit2 and ffmpeg as open source at https://github.com/crustify-rs. 2 2.1
Background Rust Safety and Soundness
Ownership, borrowing, and thread safety. Rust guarantees memory safety without garbage collection by checking ownership and borrowing at compile time: every value has a unique owner, which releases it through its type’s Drop implementation, e.g., Box<T> is the sole owner of a heap allocation and frees it when dropped; code that does not own a value accesses it through a shared reference &T or a mutable reference &mut T, whose lifetime the borrow checker bounds by that of its referent. References obey aliasing XOR mutability: a value is accessed either through any number of shared references or through exactly one mutable reference, unless it is wrapped in an UnsafeCell<T>, which permits interior mutability, as in Mutex<T> and RefCell<T>, which enforce exclusivity at run time. The compiler relies on this rule to optimize, so a program that violates it, e.g., by mutating memory through a C-held pointer while a reference to it is live, has UB. Stacked Borrows [29] and its successor Tree Borrows [68] define which access patterns are UB. A type is Send if its values can be moved to another thread, and Sync if they can be shared between threads. Arc<T>, which shares a value through a reference count that Clone increments and Drop decrements, is Send and Sync only when T is both, and hands out a &mut T only through Arc::get_mut, when the reference count proves it the sole owner. Unsafe code and soundness. The compiler cannot verify portions of programs contained in unsafe blocks. These blocks may dereference raw pointers (*const T, *mut T), call unsafe functions, including through an FFI, or implement unsafe traits such as Send and Sync. Raw pointers carry no ownership or lifetimes, so the obligation to uphold Rust’s invariants is left to the programmer. Libraries therefore encapsulate unsafe code behind safe APIs, whose implementation is sound, as defined in §1, if no program that uses the API without writing unsafe code can cause UB [30]. Safe bindings over C libraries are an instance of this design pattern: their implementation calls foreign functions and handles raw pointers, while their API is marked as safe and must uphold Rust’s ownership, aliasing, and thread-safety guarantees although the borrow checker does not check the foreign code. By convention, a C library’s raw declarations, which bindgen [51] generates from its headers, form a -sys crate, over which the safe crate is written. Rust’s smart pointers, however, are ill-suited for objects shared with C. For example, a Box<T> can adopt a C-allocated object through its unsafe from_raw constructor, but it will release the object using Rust’s allocator rather than the library’s possibly customized destructor, and it hands out &T and &mut T references to the object, whose aliasing guarantees the compiler assumes but cannot enforce, since the C side may retain and use its own pointers to it. A binding cannot simply replace these references with lifetime-bound handles: the dereference traits Deref and
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
5
DerefMut must return a plain reference, and the standard library already implements them for every Box<T>. Similarly, Arc<T> keeps its reference count in a header that it allocates itself, whereas many C objects maintain their own count, incremented and decremented through customized library routines. ffibox addresses these limitations (§3.1). 2.2
Sanitizers and Miri
Sanitizers instrument a program at compile time and report an error when an execution violates the invariants they check: AddressSanitizer (ASan) [58] detects temporal and spatial memory errors, UndefinedBehaviorSanitizer (UBSan) [41] operations that are undefined in C, such as signed integer overflow, and ThreadSanitizer (TSan) [59, 60] data races. MemorySanitizer (MSan) [65] detects reads of uninitialized memory, but requires the whole program, including its libraries, to be instrumented. Sanitizers instrument both Rust and C code, and so detect errors across the FFI boundary, but only on the paths that tests execute, and none of them checks Rust’s aliasing rules. Two tools check these rules. Miri [31], an interpreter for the Rust compiler’s mid-level intermediate representation (MIR), detects UB in Rust code, including violations of Stacked or Tree Borrows, but runs orders of magnitude slower than native code and does not check the foreign code that it calls. BorrowSanitizer [42], which is still under development, instruments Rust together with statically linked C code to detect violations of Tree Borrows, including those caused by accesses on the C side. Crustify’s agents use these tools to catch regressions and to reproduce the flaws they find. 2.3
LLM Agents
An LLM agent runs a language model in a loop that interleaves reasoning with actions, such as tool calls, whose results inform its next step [73]. Such agents resolve real GitHub issues by navigating a repository, editing its files, and running its tests [72, 76]. Coding agents such as Claude Code [2] and Codex [47] run this loop in the user’s workspace with tools to search, read, and edit files and to execute shell commands; Crustify runs each of its agents as a session of such an application (§3.5). When a session approaches the model’s context limit, the application compacts it into a summary written by the model, which may omit instructions given early in the session, so persistent instructions are kept in files that the application loads into every session [2]. A model’s accuracy declines as its input grows [16, 25, 39] and as the number of steps in a task increases [64], which decomposing a task into simpler subtasks can mitigate [32]; Crustify therefore assigns each agent a single batch of bounded size (§3.3). Without external feedback, models do not improve their answers to reasoning tasks by revising them, and at times change correct answers into incorrect ones [26], whereas feedback from executing the code [12] or from a stronger model [46] helps, and LLM evaluators favor their own outputs [48]. Crustify’s agents therefore receive external feedback from the compiler and linter, from UB, equivalence, and unit tests run under sanitizers and Miri, from line coverage on both sides of the FFI boundary, and from ffimap, and its reviewers can run on a different model than the binders and accept a UB or equivalence finding only if it reproduces through the Rust API (§3.4.4). 3
Crustify
Given a C repository, Crustify generates a safe Rust crate that wraps its public API and is designed to be both sound and ergonomic (§1): rather than uphold a memory-safety discipline themselves, its consumers use the idioms that Rust programmers know from the standard library, i.e., owners that release on Drop and duplicate on Clone, borrows that the borrow checker keeps from outliving what they reference, and errors as Results. We therefore design for ergonomics by mirroring the standard library’s primitives as closely as C allows, e.g., with owners that behave as a Box or an Arc, and borrowed arrays that behave as slices. The crate confines unsafe code to its internals,
6
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen Agent tools
Planning
goals
User Planner
brief
CRUSTIFY.md settings
ffibox
Module decomposition
Scheduling
modules.json dependencies.json
schedule.json
primitives
Wavefront graph, ownership
Binding and review waves Assistant
C repository
co-translator
foundational module 𝑗
Safe Rust crate
. . . module 𝑛
Binder batch 1
···
Binder batch 𝑘
ffimap wave 𝑖
safety scans
review wave 𝑖
sanitizers, Miri
Tests Reviewer · · · Reviewer batch 1
batch 𝑚
Specification: workflows, decomposition, scheduling, binding, testing, review, conventions
Fig. 1. Overview of Crustify. The assistant scopes the campaign with the user; a planner decomposes the target into modules and schedules their items into waves; binders and reviewers process each wave’s batches concurrently; all agents follow a common specification and use the same tools.
behind the safe API, and Crustify checks it with coverage-guided tests under sanitizers and Miri and with agentic review. Crustify divides the work between LLM agents and deterministic tools: agents run tools, generate code and tests, and make the semantic judgments that deterministic tools cannot, while the tools take on the repeatable, mechanical tasks. It consists of three tools and a common specification that LLM agents follow (Figure 1, §3.4): ffibox provides the C/Rust interop primitives from which agents build the bindings (§3.1), ffimap maps their unsafe surface and their use of ffibox primitives (§3.2), and Wavefront schedules the target’s types and symbols into ordered waves of independent batches, answers the agents’ queries about the code base, and stores the ownership and lifecycle contracts they derive (§3.3). An assistant agent, Crustify’s co-translator, drives the campaign through three kinds of agents that it spawns: planners decompose the target into modules and schedule their types and symbols into waves of batches, a binder wraps each batch of a wave, and a reviewer audits each batch of the review wave that follows it (§3.4, §3.5). The user fills in the campaign’s settings, live or in CRUSTIFY.md, approves its brief, and can intervene at any point; between such approval gates, the assistant runs autonomously. 3.1
ffibox: Primitives for Safe FFI Bindings
A designer of a safe binding interface must decide how every C type and pointer that crosses the FFI boundary is owned, borrowed, shared, and released—and must do so without introducing UB, since the Rust compiler cannot check the unsafe code that implements these decisions. This requires recovering from the C code each type’s lifecycle routines, i.e., its constructors, destructors, and cloners, and each pointer’s properties: the type it points to, whether it owns or borrows its pointee, whether access through it is shared or exclusive, whether it may be null, and whether it points to a single object or to an array. C often settles these properties only at run time or by convention: ownership may depend on a flag (§3.4.3), pointers may be type-erased (void *), a type may have
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
7
several destructors or separate its allocation from its initialization, and a pointer to an array looks like a pointer to a single object. ffibox encodes these decisions in a small set of generic primitives that mirror Rust’s standard ones (Table 1), so that Rust’s type and borrow checkers enforce them. It thus serves both goals of a safe crate (§1). For ergonomics, its primitives give consumers Rust’s ownership, borrowing, and threading idioms over C objects through the interfaces of their standard counterparts, e.g., a CBox releases its object when dropped and a CArc shares it when cloned, as Box and Arc do. For soundness, their unsafe code is written and checked once, in ffibox, and what each binding must still justify is a fixed set of unsafe trait implementations, one per type and obligation (C6), most of which macros emit, so that auditing a binding’s soundness reduces to checking these implementations against the C routines they name, rather than every function that touches C. The primitives are built around one invariant, which rules out the aliasing violations that references into C memory cause (§2.1): no Rust reference (& or &mut) to memory that C may alias is ever formed. We describe the primitives along six challenges (C1–C6). The invariant has one exception: a global that is immutable after initialization is exposed as a plain shared reference, sound because neither side writes it (§3.4.3). (C1) Type layout. Each C type foo gets a layout type Foo, a #[repr(transparent)] newtype over the bindgen type with private fields and no Drop. Code outside the binding crate thus cannot build a Foo from a raw value, and releasing it is left to its owner (C2). A zero-sized PhantomData<*const ()> field withholds Send and Sync, requiring types to opt in explicitly (C4). Field accessors live on the borrowed handles (C3), never on Foo, so the only references that cover a C struct’s bytes are those to a Foo that Rust holds inline, e.g., a zero-initialized local or the contents of a CVal (C2). Such a value moves by copy and is therefore sound only while C holds no pointer to it, so the binding decides which types consumers may hold by value: a type that C registers or that is address-sensitive (e.g., self-referential) is held only behind a CBox. (C2) Ownership across the boundary. ffibox provides three owners: CBox<Foo, P> is the sole owner of an object behind a pointer and releases it through the policy P, mirroring Box; CArc<Foo, P> is one of several reference-counted owners, mirroring Arc; and CVal<Foo, P> holds an object inline and disposes of its resources (e.g., *_clear) on drop. The policy P names the C routine that releases the object, and the routines that allocate and clone it where they exist; it is usually a zero-sized type, but may carry run-time state as fields for routines that need data the object does not store. Since these routines are bound to the policy rather than to Foo, a type may have several policies, i.e., several pairs of constructors and destructors. A policy that also allocates, e.g., by calling the type’s C constructor or a generic memory allocator, gives CBox and CArc safe constructors, so an object built in safe Rust is always released by the routine paired with its allocator. (C3) Borrowing C memory. Forming a native &mut Foo over memory that C may still alias would assert uniqueness that C does not respect, which is UB. Such a reference would also assert that the bytes are valid, which holds only once C has initialized them, often in stages after allocating the storage. Instead, every layout type defines two lifetime-annotated handles, FooRef<’a> and FooMut<’a>, each one pointer wide and bound to the memory it references. The getters and setters that Crustify synthesizes live on the handles and project fields through raw pointers, so the borrow checker tracks the handles’ lifetimes while no reference into C memory is created. The owners of C2 hand out only these handles, never a native reference to the object. (C4) Sharing across threads. Wrapped types withhold Send and Sync by default; a wrapper opts in with an explicit safety argument. CArc hands out the exclusive handle only when the reference count proves it the sole owner, and for objects protected by a C lock, a CGuard exposes a FooLocked<’a>
8
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen
Table 1. ffibox primitives and the standard-library counterparts whose interfaces they mirror. Challenge
ffibox primitive
Standard counterpart
C1 C2 C2 C2 C3 C4 C5 C5 C5
Foo (layout newtype) CBox<Foo, P> CArc<Foo, P> CVal<Foo, P> FooRef<’a> / FooMut<’a> CGuard / FooLocked<’a> CVec<T, P> CStrBox<P> CSlice<’a, T> / CSliceMut<’a, T>
— Box<T> Arc<T> T (by value) &T / &mut T MutexGuard<T> Vec<T> CString &[T] / &mut [T]
handle that reaches the protected state only while the lock is held. Both rely on traits that register the locking routines and the ownership check for a given type (C6). (C5) Buffers and strings. CVec<T, P> owns a (ptr, len) array released by a C destructor, mirroring Vec, and CStrBox<P> owns a NUL-terminated char * with read-only CStr/str views. Borrowed runs of wrapped C objects—a buffer’s, a struct field’s, or a getter’s—are exposed as CSlice<’a, T> and CSliceMut<’a, T>, which yield element handles rather than &[T]/&mut [T], so no reference to an element is formed. (C6) Type safety. Each obligation is an unsafe trait that a binding implements once per type and that the primitives require as bounds. CCell links a layout type to its C type and to its Ref and Mut handles, so that generic code, such as CBox<T, P> or CSlice<’a, T>, reaches C objects only through handles. The lifecycle traits are implemented on the policy (C2): CDrop releases an object, CDupClone and CRefClone clone it by deep copy or by reference count, CDispose releases the resources of a value held by a CVal, and the construction traits CAlloc and CNew extend CDrop, so every safe path into an owner goes through the policy that releases it. CGuarded registers a type’s C lock, and CZeroable asserts that the all-zero bit pattern is a valid value. Macros emit the common implementations: define_ctype! a layout type with its handles, and the policy macros bind a C routine at its exact pointer type, so that a routine for the wrong type fails to compile. Since these macros expand to unsafe impls, the binding’s author need not write unsafe to invoke them, but each invocation carries the trait’s proof obligation, e.g., that the bound routine indeed releases or clones the object. 3.2
ffimap: Mapping Safety on the FFI Surface
A binding falls short of its goals at specific sites: public unsafe functions and raw pointers on the safe interface cost ergonomics, and C objects managed outside ffibox’s primitives, e.g., through a hand-written Drop, escape ffibox’s audit. ffimap is a static analyzer that locates these sites. Since its analysis has precision gaps (discussed below), ffimap acts as a linter that sets a target for each property and points to the sites to review, rather than as a verifier. ffimap is a rustc driver that runs after type checking and reads the crate’s high-level intermediate representation (HIR) together with its inferred types. It thus counts after macro expansion and cfg selection, resolves type aliases, re-exports, and generic instantiations to the types they denote, and decides visibility by rustc’s effective visibility, i.e., whether an item is reachable from outside the crate, rather than by its pub keyword (Appendix C compares it with syntactic tools). Besides the crate, ffimap takes two lists from its user: the -sys crates, whose #[repr(C)] types it takes
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
9
as C’s, and the names of the crate’s seams, which we define below. Its counters fall into two categories. The generic counters apply to any Rust crate, which lets us compare our bindings with community-maintained ones (§4.1). The ffibox counters read each type’s role from declarations that ffibox and Crustify’s conventions add, which we list below. Every count comes with the source sites behind it, and tests and the generated -sys crates are excluded. Agents run ffimap to locate residual unsafe code and raw pointers in their batch, and the assistant records a scan after every batch, wave, and module, from which our evaluation draws its figures (§4). Seams. A seam is a function that only converts between a wrapper and the C value it wraps, such as from_raw, which adopts a C pointer, or as_ptr, which hands one out. Seams are unsafe or expose a raw pointer by necessity, since no Rust type can express the validity of the pointer they convert, but an ergonomic binding never requires a consumer of its safe API to call one. Seams serve code that itself calls C, i.e., bindings built on other bindings, tests against C, and, as a fallback, consumers that need a C item that the bindings do not cover yet. ffimap counts them apart from the surface that consumers rely on, and takes them by name, since a signature does not show that a function only converts. Unsafe surface. The generic counters count the public and private unsafe fns, trait methods included, the unsafe blocks and the lines they span, the unsafe impls, and the calls whose callee carries a foreign ABI, e.g., extern "C", including calls through function pointers. They split the public unsafe fns by what made them unsafe: a method of a trait implementation whose trait declares the method unsafe, such as CDrop::c_drop, which an implementation cannot declare safe; a seam; and the remainder, which the crate chose to make unsafe. Raw pointers. The generic counters count every *const T and *mut T in a signature or a field, including NonNull<T> and AtomicPtr<T>, which wrap one, and pointers nested in slices and generic arguments. Each position is classified by the visibility and safety of its function, or by the visibility of its type and field, so that the pointers on the safe public interface, i.e., in the public fields of exported types and in the signatures of exported safe functions, are told apart from those that only unsafe callers or the crate itself reach. The positions in the methods of trait implementations are counted apart, since the trait rather than the crate fixed them, and so are those of seams. ffibox declarations. Without ffibox, a newtype that covers a C struct, a borrowed handle to one, and a Rust value type look alike, and a function that wraps a C function cannot be told from a helper. The ffibox counters resolve these cases from four declarations. (i) A layout type is a type that implements CCell (C6), whose associated types name the C struct it covers (CCell::C) and its shared and exclusive handles (CCell::Ref, CCell::Mut). (ii) A policy is a type that implements one of ffibox’s lifecycle traits (C6). (iii) An owner or a borrow is one of ffibox’s owning or borrowing primitives (Table 1). (iv) A wrapper of a C function or field is an item that carries a rustdoc alias naming it, e.g., #[doc(alias = "git_repository_open")] or #[doc(alias = "git_oid.id")], which Crustify’s conventions place on every item that stands in for a C item, and which ffimap resolves against the -sys crates’ declarations. ffibox use. From these declarations, the ffibox counters inventory the C surface: the functions that the -sys crates declare, those that the bindings call or take the address of, and those that a wrapper names; and the structs that a -sys declaration puts behind a raw pointer, those that the bindings name, and those that some CCell::C names. For each type, they record which of ffibox’s traits it implements and whether a macro of ffibox, such as define_ctype!, emitted each implementation, which its source span shows. In public signatures and fields, they count the owners and borrows taken and returned, and classify each mention of a layout type Foo by
10
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen
its innermost holder: an owner (CBox<Foo, P>), a borrow (CSlice<’a, Foo>), a standard owner (Box<Foo>, Vec<Foo>), a reference (&Foo, &mut Foo, &[Foo]), or none, i.e., Foo by value. Mentions behind a raw pointer or in PhantomData are not counted. Outside ffibox. A binding bypasses ffibox wherever it manages a C object or a pointer by hand, or forms a reference that ffibox’s invariant rules out. The generic counters find the types that implement Drop by hand, or a reference-forming trait, i.e., Deref, AsRef, Borrow, Index, or their mutable counterparts, and the types that store a raw pointer, both those that declare one in their own fields and those that reach one through their crate’s or the -sys crates’ types, but not inside another crate’s owner, such as Vec or CBox. The ffibox counters single out the raw-pointer types that also implement one of ffibox’s traits, and thus declare the pointer’s lifecycle through it. They also count the -sys structs that the bindings use without a layout type or hold raw, by value, in public signatures, and the references &Foo, &mut Foo, and &[Foo] to a layout type Foo in public signatures and fields, except for the receivers that ffibox sanctions: those of Foo’s inherent as_ref and as_mut, which turn a Foo that Rust holds inline (C1) into its handles (C3), and those of trait methods, whose shape the trait fixes, e.g., Clone::clone(&self). Only the receiver is exempt, so a trait method’s other arguments and its return count like any function’s. Known limitations. ffimap is neither sound nor complete. It misses the references that an autoref through a raw pointer forms, e.g., (*p).field, since neither a signature nor a cast carries them, and the references to -sys structs that no layout type covers, since it finds a struct’s mentions through CCell::C. Conversely, it counts every reference to a layout type outside the sanctioned receivers, even where Rust holds the value, e.g., &Oid. Its ffibox counters are only as accurate as the declarations they read: seams, as_ref, and as_mut are matched by name, so that a conversion named otherwise is counted like any function, and a wrapper without its rustdoc alias is missed. 3.3
Wavefront: Dependency-Ordered Scheduling
Wrapping a repository-level target requires ordering its types and symbols such that every item is wrapped after the items it depends on. In C, this order often forms cycles and is partly hidden from type declarations, in casts that implement polymorphism, type-erased void * slots, and macros that implement generic types (§1). Wavefront therefore combines a symbolic analysis with LLM judgment: its graph is a deterministic function of the entities (T1) and relations (T2) that it extracts from the code, and of a small table of corrections (T3) that agents author for the relations that require judgment. We describe Wavefront along four challenges (D1–D4). (D1) Dependency graph. Wavefront extracts entities and relations with 25 queries over a CodeQL [23] database of the target, i.e., a relational representation of the program that CodeQL records from its build and that Datalog-based QL queries [4] analyze, and composes them into a single directed graph whose nodes are types (structs, unions, and enums) and symbols (functions, globals, macros, and callbacks). An edge 𝐴 → 𝐵 then states that 𝐴 requires 𝐵 to be emitted first. Type-totype edges follow struct field types; symbol edges follow the types in a symbol’s signature and body, its callees, and the globals it accesses. Function-pointer typedefs are treated as symbols, reached through the signatures and indirect calls that use them. Typedef chains are resolved to their underlying tags, so that a type and its aliases form one node. Lifecycle routines (constructors, destructors, and cloners) remain ordinary symbol nodes rather than being attached to their types: C does not distinguish them from a type’s other functions or from free functions, so only agents can identify them, and the graph does not depend on their findings. (D2) Type erasure and polymorphism. Wavefront recovers the relations that casts and type-erased slots hide from the conversions that the code writes, without data-flow analysis. A function whose
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
11
body reads its void * parameter as a type T depends on T, as on a typed parameter. A type stored into or read from a type-erased field is a variant of the field’s owner, and depends on the owner as a derived type on its base, so that the owner can become generic over its variants. A cast between two named types, written directly or through one slot, is an edge in its written direction, so that an upcast orders the base first; where cast edges form a cycle, an edge survives only toward the type that is cast to or from strictly more types, so that the edges run from the instances of a family up to its hub. Each conversion is recorded at the access that writes it, and nothing follows a value through locals, memory, or calls. This keeps the extraction cheap and predictable, but misses relations, e.g., a void * handed on through a chain of calls, and adds spurious ones, e.g., a slot that one file reuses for an unrelated type. The relations that require judgment are left to agents: the planner follows each unresolved type-erased value hop by hop and records its conclusions as T3 rows that add a missed relation, drop a derived cast edge, fix the direction of a base–derived pair, or name a macro as the generator of a family of types, each with a report that gives its evidence. Wavefront checks each row only for well-formedness against the extracted facts, e.g., that the entities it names exist, and not the relation itself. Rows can, however, only add or remove cast and erasure edges, never the field, signature, and body edges that follow from declarations, so that a wrong row can misorder a family of related types, but cannot schedule an item before a type that its fields or signature need. In the public-signature graph, cast edges are added only between items that fields and signatures already reach, so that a cast to an internal type never pulls that type into the schedule. The graph thus orders each family of related types after its root, i.e., its base, the owner of the field its members are stored in, or the macro that generates them, so that Crustify can bind the root first as a generic Rust type or trait (§3.4.3). (D3) Ordering and batching. Wavefront orders the target’s units, i.e., its types and symbols, except enums and macros other than type generators. It proceeds in five steps. (1) Grouping. Wavefront groups the units by the modules that its configuration lists and condenses the graph into a module graph. An edge between two modules weighs the distinct units that one uses of the other. Without modules, each unit forms its own group, and the order is flat. (2) Cutting cycles. Wavefront finds the strongly connected components (SCCs) of the module graph [67] and orders each one’s members by how many more of its edges reach them than leave them. It counts each dependency once, since cutting it leaves one binding to write. Every edge to a later member becomes a cut. The order is deterministic, but not a minimum cut. (3) Layering. Wavefront assigns each module to a layer by its longest dependency path, as in Sugiyama et al. [66]. It then repeats steps 2 and 3 on the units of each module. (4) Recording cuts. Each cut is recorded at both of its ends, and a cut between modules also lists the unit edges it crosses. Within a module, Crustify binds a cut dependency to its raw FFI type, and the batch that later wraps it upgrades the binding. Between modules, Crustify wraps the crossing units before their consumer (§3.4.2). (5) Waves and batches. Each layer of a module’s units forms a wave, which records the waves it depends on. Crustify runs each module’s waves one at a time, modules that do not depend on each other concurrently, and the batches of a wave concurrently (§3.4.3). Type batches are bounded by a number of types and close early once their declared fields reach a threshold. Symbol batches are bounded by a number of symbols and lines of function bodies. Adjacent layers of one module whose units fit into one batch are merged. Since Wavefront always orders the whole target, a schedule that covers only part of it is part of the same order, so separate planning sessions produce consistent schedules. (D4) Scoping the workload. Wavefront takes as input a JSON configuration with three parts. The closure names the program that it analyzes: the target’s implementation files, i.e., its sources and private headers, and the API headers that publish its interface, each with paths to exclude. The
12
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen
optional modules group the closure’s units for ordering (D3). The optional seeds select the files, or the types and symbols within them, that a schedule starts from. Wavefront builds its graph either over the whole implementation or over the published API only: for wrapping, it traverses a public-signature graph that omits function bodies, retains the fields of public definitions, and keeps forward-declared types opaque. Items that the closure uses but does not define, e.g., from libc, are external: they remain dependencies of the units that reach them, but are never units themselves, and Crustify wraps them in campaigns of their own (§3.4.2). A schedule holds its seeds and every unit they depend on, so that the schedule of one module, or of a few named types and symbols, is complete on its own. Queries. Wavefront answers agents’ queries from the same relations it schedules from: a type’s fields and the functions that access each field, a function’s callers and callees, including calls through callbacks, the symbols that take a given type, the functions that take or return a type-erased or string pointer, optionally restricted to those that reach a given routine within a number of calls, which locates candidate lifecycle routines, and the lifecycle routines that agents have identified for a type. Coding agents otherwise search the code as text [2], which misses uses that a macro expansion or a typedef alias hides and conflates same-named fields of different structs. Accuracy. Wavefront’s guarantees are relative to its inputs: CodeQL resolves the graph from the compiler’s view of the build, so code that the build configuration excludes is not seen. The schedule is a deterministic function of the extracted facts, the T3 rows, and the configuration; its seeds select part of the order but never change it, and ownership findings never move a node. Its only approximate part is the cast and erasure edges (D2), whose errors may keep an agent from recognizing a family and binding it generically, but cannot invalidate the order of the members’ fields and signatures. Ownership store. Wavefront records the ownership and lifecycle properties that agents establish in an ownership store. Each pointer in a field, an argument, a return value, or a global is recorded with the properties that select its ffibox representation (§3.1): its cardinality (a single object, an array, or a string), whether it is owned or borrowed and from which source, whether it may be null, and whether its pointee is written through it. A pointer that is both owned and borrowed has dual ownership, i.e., it owns its pointee on some paths and borrows it on others; for an out-parameter or an array of pointers, the record also gives the ownership of the inner pointee. A symbol records its lifecycle role for one of its pointers: a dropper releases an object and its storage, a disposer only the resources it holds, a cloner copies it deeply or by reference count, and a constructor allocates or initializes it. A type may thus have several routines of each kind. Fields may further be marked as reference counts or as protected by a lock, and callbacks may be forked by call site. Wavefront checks each submission for consistency with the record that it derives from the code and with the rules of each role, e.g., that a destructor’s argument is owned and a cloner’s borrowed, and rejects the whole submission otherwise; like the checks of T3 rows, these rule out malformed findings, not wrong ones. Appendix A shows how such findings map to ffibox primitives. 3.4
Workflow
There is no established methodology for generating safe bindings for an entire repository. Crustify therefore defines one in a specification, a set of descriptive documents that state what each artifact of a campaign holds, how each decision is made, and what each procedure produces, without naming who executes a step. It comprises workflows for whole campaigns, how-tos for single items, and schemas for the artifacts that steps exchange, and states each rule once, so that a change reaches every agent; role prompts assign its steps to agents (§3.5). A wrap campaign proceeds in
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
13
four phases: setup establishes the campaign’s scope and prepares the target; planning decomposes it into modules and schedules the workload in dependency order; binding generates the bindings, writes coverage-guided tests to reveal their flaws, and runs the regression suite after every batch; and review audits the bindings of each wave before they are promoted. Since the workload is scheduled per batch, a target can be wrapped incrementally, so that maintainers can ship bindings at intermediate milestones rather than only once the entire repository is wrapped. 3.4.1 Setup. The campaign’s scope, i.e., the entire public API, some of its modules, or named types and symbols, is settled with the user before setup, in the campaign’s settings and brief; setup then prepares the target for the phases that follow. Instrumented builds. The target library is built once in several configurations, which all later steps reuse: a plain build, which serves as the functional baseline; a coverage-instrumented build, which measures C coverage during testing; an ASan/UBSan build for memory-safety and FFI tests; a separate TSan build for race tests, since TSan’s runtime conflicts with ASan’s; and a BorrowSanitizer build for aliasing violations. Our campaigns omit MSan, which reports false positives unless every part of the program is instrumented, including prebuilt shared libraries, and Miri needs no C build, as it interprets only the Rust code. The library’s own test suite is run on the plain build to record a baseline that the final bindings must preserve; tests that are unstable or need infrastructure the host lacks, e.g., a network service, are each disabled with its reason. Finally, a CodeQL database is created from a traced plain build, from which Wavefront extracts its entity and relation tables (§3.3). 3.4.2 Planning. Planning decomposes the target into modules and schedules their types and symbols in dependency order. The decomposition follows the codebase’s own organization rather than a judgment of how it should be organized, so that the Rust tree mirrors it. Module decomposition. The repository is decomposed into the link units that its build produces, i.e., shared objects, static libraries, and executables, as its linker outputs show, and each link unit into modules: a module is a single translation unit together with the headers that carry its name, internal or installed. A shared header, which carries no single translation unit’s name, is split item by item, each item going to the translation unit that implements its lifecycle or logic. Every translation unit that the build compiles in scope thus belongs to exactly one module. This step produces modules.json, which records the link units, their modules, and the dependencies between modules, including the kinds of items consumed across them. The out-of-tree libraries that the target links, e.g., libz, are recorded once in dependencies.json, with the release that the target links and its source, and each module records what it consumes from them. Decomposition also identifies the foundational modules, i.e., the translation units that define the allocators, releasers, disposers, and cloners of type-erased memory, and those whose string routines build only on these. Every other module may depend on them, and their routines are bound as ffibox policies through which the other modules’ bindings allocate and release their objects. Scheduling. Modules are ordered bottom-up by their dependencies. The foundational modules depend only on each other and run first, those of type-erased memory before those of strings, which call them. Wavefront orders the modules and cuts their cycles (D3, §3.3); the units that a cut dependency crosses are wrapped before their consumer rather than reached through raw bindings. The out-of-tree libraries in dependencies.json are wrapped lazily: before a wave that uses entities a library’s wrapper lacks, the wrapper’s own campaign binds just those, and the target’s workspace pins the result, so that a wrapper covers only what its consumers use and later campaigns extend it. Within each module, the types and symbols are scheduled into waves of size-bounded batches
14
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen
of mutually independent items (D3–D4), so that type batches land before the symbol batches that depend on them. The order also follows the casts and type-erased slots of D2, so that the root of each family of related types is bound first. The schedule is recorded in schedule.json. Rust tree. Once the plan is complete, the Rust tree is scaffolded for the link units in scope: the safe crate, with one Rust module per link unit, and one -sys crate per link unit, whose bindgen [51] input covers the target’s C code and whose allowlists the batches fill as they bind items. Everything below a link unit, e.g., a module’s source file, is created lazily, before the module’s first wave. 3.4.3 Binding. The binding phase runs each module’s waves one at a time, in schedule order, and the batches of each wave concurrently. Modules that do not depend on each other run concurrently, each on a branch of its own that is integrated into the working branch once its last wave has been promoted, so that each module finishes, and can ship, as early as its dependencies allow. Each batch wraps its items with ffibox primitives, refines them through compiler and lint feedback and feedback on their unsafe surface (§3.2), and tests them (§3.4.3). The central decision in every batch, whether it wraps types or symbols, is how to represent each pointer that is exposed on the safe API or crosses the FFI boundary. Pointer and lifecycle analysis. Each batch starts by establishing the ownership and lifecycle properties of its items, i.e., the properties that select each pointer’s representation and the routines that construct, release, and clone each type, and records them in the ownership store (§3.3) for later batches and review. C declares few of these properties, so the batch derives them from every path that stores, transfers, clones, or frees a pointer, rather than from names and comments, which it overrides where the code disagrees. The findings guide the batch’s representations, accessors, and tests, but no step generates code from them. Pointer representation. Crustify aims to represent each FFI pointer with an ffibox primitive (§3.1), so that the compiler checks its ownership, mutability, nullability, and cardinality. C programs, however, settle these properties by convention, and the same pointer often carries different semantics depending on how a function uses it or on a flag that the caller passes; ffmpeg’s av_dict_set, for instance, takes ownership of the strings it receives only if the caller sets a flag. A Rust signature that mirrors the C one would either be unsound or shift the distinction onto the caller as an unsafe contract. Crustify instead forks such pointers: it emits one Rust form per semantics that the analysis reveals, each of which fixes ownership, mutability, nullability, and cardinality in its type, e.g., an owned and a borrowed form for a pointer with dual ownership, so that the compiler checks every use. Forking applies to struct fields, function signatures, and callbacks alike. For type-erased pointers, Crustify additionally makes owners and borrowed handles generic over the pointee, bound by CCell, so that only types with an ffibox layout, which never forms a reference into C memory, can stand in for it. Appendix A gives examples of the bindings that the binding phase emits, mapped to the ffibox primitives they use. Types. A type batch contains structs and type-generating macros, i.e., macros whose expansion defines an entire aggregate type, and wraps each type’s layout and field accessors together with its lifecycle routines, i.e., its constructors, destructors, and cloners. A union is bound in the batch of the struct that holds its discriminator, as a Rust enum tagged by it when the discriminator determines the variant, and enums are imported from the raw bindings as constants. The batch emits a layout newtype over the type’s bindgen representation, together with its borrowed handles, usually through ffibox’s define_ctype! macro. Layouts and handles that carry lifetimes, e.g., a handle that borrows both an object and the object it was looked up in, are written by hand, since the macro covers only a single handle lifetime. The batch also emits accessors for the fields that
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
15
the schedule lists, i.e., every field of a type that the API headers define, and otherwise those that its consumers access: a getter and a setter for each, forking pointer fields as described above: an owned field gets a setter that releases the previous owner and getters that either take or borrow it, and a borrowed field ties its value to the layout’s lifetime. Families of related types are bound around their root, which the schedule orders first (§3.4.2). A polymorphic base becomes a generic type bounded by a marker trait, and each derived type implements the trait, so that a derived object is used where its base is expected without unsafe casts or transmutes. A type-erased field likewise becomes generic, bounded by CCell and, when the types stored in it are known, by a marker trait that these types implement. A type-generating macro becomes a generic Rust abstraction that its instances implement, bound by hand, since define_ctype! covers a single C type. Foundational modules. The batches of the foundational modules (§3.4.2) run before those of any other module, since their routines allocate, release, and copy the plain void * and char * buffers and strings that the other modules use. Each routine with a lifecycle role becomes an ffibox policy that calls it through the FFI, rather than an ordinary safe function: the allocator, releaser, and cloner that manage the same memory, e.g., av_malloc, av_free, and av_memdup, form one policy, which backs CBox owners of single objects and CVec owners of arrays, or one policy each, and the policy of the string routines backs CStrBox (Appendix A). Every later batch reuses these policies, so that the buffers and strings it binds are released through the same routines as in C. Allocation is otherwise enclosed in each type’s constructor, bound through ffibox’s CNew trait, and generic allocators are exported only for objects whose type no C constructor allocates; these return an initialized object only for types that implement CZeroable, and MaybeUninit<T> storage otherwise. Symbols. A symbol batch contains functions, globals, and callbacks, i.e., function-pointer typedefs. Macros are not published as Rust APIs of their own: an alias is replaced by its symbol’s wrapper, a function-like macro is bound through the smallest C shim it needs, and a constant macro uses its bindgen declaration. For each function, the batch emits a wrapper whose arguments and return value are safe handles; the wrapper reconstructs raw pointers only at the FFI call, inside a small unsafe block that documents why the call is sound. A function whose pointer arguments or return value admit several semantics is forked into one wrapper per semantics, as described above, and a function that takes or returns a type-erased value becomes generic over the types it is read as, rather than exposing a void pointer. A function that operates on a type becomes a method of FooRef, FooMut, or FooLocked, as it reads, mutates, or requires the lock of the object, and returns values bound to the object’s lifetime rather than to the handle’s borrow, which lifetime elision would choose. A wrapper is declared unsafe only when no type can express the caller’s obligation. A global is wrapped as a ’static borrow, never as an owner, since its storage is never released. Its access discipline follows from all of its accesses in C: a global that is immutable after initialization gets a plain shared reference, the exception of §3.1; one that C protects with a lock is reached through ffibox’s CGuardedRef, which hands out a handle only while it holds the lock; a threadlocal global gets a handle that is borrowed only on the current thread; and any other mutable global gets unsafe accessors whose contract excludes concurrent access. A callback becomes a newtype over the raw function pointer, whose call method converts safe arguments to raw ones and the result back. When the functions that invoke a callback type realize different ownership contracts for its arguments, e.g., some transfer an argument’s ownership to the callback while others lend it, the callback is forked into one wrapper per contract, which the ownership store records with the invokers that realize it. Testing. Each batch writes four kinds of tests for the items it wraps. Equivalence tests run the C implementation and the Rust API on independent but equivalent inputs and compare return values,
16
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen
out-parameters, buffers, callback invocations, and lifecycle effects after each call of a multi-call sequence. Safe UB tests are compiled with #![forbid(unsafe_code)] and drive the safe API, including with programs that the compiler must reject, and must not trigger any sanitizer; unsafe UB tests call each public unsafe function within its documented contract; and unit tests check internal routines. Each prepared instrument is a separate obligation, e.g., use-after-free and double frees under ASan, races through asserted Send and Sync implementations under TSan, UB within the Rust code, including aliasing violations, under Miri, and aliasing violations across the boundary under BorrowSanitizer. The batch then measures the line coverage of its items in C, with the coverage-instrumented build, and in Rust, adds tests for reachable uncovered paths, and reports the paths it deems unreachable. Coverage counts only non-test code, since tests would otherwise be credited for executing themselves; each kind of test is measured alone, and the total is the union of their covered lines. Every batch, whether it wraps or reviews, ends with a regression gate: it rebuilds the workspace, runs the linter, and runs the full test suite under ASan, UBSan, and TSan, against the matching sanitized C build. Two instruments run less often: Miri runs once per wave, over the whole workspace, in the wave’s review; and BorrowSanitizer, the slowest instrument, which in our experience also reports false positives at the foreign boundary, runs only on the tests that a batch expects to trigger it, and over the whole workspace in the review of a module’s last wave (§3.4.4). 3.4.4 Review. Each binding wave is followed by a review wave, which audits the landed batches for undefined behavior, equivalence drifts, and coverage gaps before any dependent wave starts. We believe that review is crucial between waves, because a flawed binding propagates into the bindings that depend on it, so that fixing it later requires larger, breaking changes to their APIs. Each review batch covers the commits of one or more binding batches of the reviewed wave, which it reviews for the objectives that the brief selects, against the same binding how-to that the binders follow; its reviewer can run on a different model than the binders whose work it audits (§4.1). Each review wave also runs Miri over the whole workspace, and the review of a module’s last wave runs BorrowSanitizer over everything that the module landed, once on the wave as landed and once on the reviewed tree. Each defect found is filed as a report, and fixed under the fix objective, which our campaigns select, in one of eight categories: lifecycle, a missing lifecycle routine or owner; unsafe, an unsafe function or raw pointer on the public API that a safe binding could replace, located with ffimap; obligations, a safety obligation or its justification that is wrong or incomplete; UB, through the safe or the unsafe API; equivalence, a divergence from the C implementation; internal, a defect in private code that the unit tests missed; conventions, a deviation from Crustify’s coding conventions; and misc, any other defect. A UB or equivalence candidate becomes a finding only if it reproduces through the Rust API, as a standalone reproducer that triggers a sanitizer or fails an equivalence comparison; the reproducer is committed before its fix, so that it can be rerun against the unfixed code, and the fix turns it into a regression test. Coverage gaps are filled with tests rather than reports, since the coverage measurements show the difference. Reviewers also verify the ownership and lifecycle findings of the reviewed items, e.g., that no constructor, destructor, or cloner of a type was missed, and resubmit each finding they correct or add to Wavefront, with a report of the evidence. A reviewed wave is promoted once its review batches have landed. 3.5
Multi-Agent Architecture
Crustify executes the workflow with four agent roles: an assistant, Crustify’s co-translator, which interacts with the user and owns the campaign, and planner, binder, and reviewer agents, which it spawns. The assistant executes setup (§3.4.1), planner runs execute planning (§3.4.2), binders execute the binding phase (§3.4.3) and reviewers review (§3.4.4), one batch each, and the assistant
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
17
owns every step in between, e.g., scaffolding the Rust tree and preparing, landing, and promoting each wave. Each agent runs as a session of an off-the-shelf coding agent, e.g., Claude Code or Codex, and roles can use different models. Only the assistant runs interactively; every other agent runs headless in its own git worktree and branch, and sees only its own worklist. A binder or reviewer lands its commits on its wave’s branch one batch at a time, under a lock: it rebases onto the branch, reruns the gates that the rebase could affect, and fast-forwards the branch, so that concurrent batches never overwrite each other’s work. Prompts. Each agent’s prompt consists of three layers: the specification (§3.4), included whole in every role that follows it; the role prompt, which states the role’s inputs, the steps it owns, and the commands and judgments that the specification leaves to it; and tool specs, which add one tool each, i.e., ffibox, Wavefront, or ffimap: a header that every role includes, and one file per specification document that the tool serves, which follows that document wherever it is included, so that removing a tool’s specs removes it from every role while the specification stays unchanged. The prompts state every step, so that an agent neither omits one nor drifts from it over a long run, but contain no code examples and leave the execution to the agent. Crustify places the whole prompt in the coding agent’s system prompt, which the agent does not compact when its context fills up (§2.3). When a task needs more than its role’s prompt covers, e.g., after the user changes the campaign’s direction, the assistant writes additional instructions for that agent, which take precedence over the rest of its prompt and name every departure from the specification explicitly, so that the agent does not reconcile it away. Assistant. The assistant is the user’s entry point. Started with an instruction to run the campaign, it executes the workflow end to end; otherwise, it serves the user’s requests, using the workflow as a reference, e.g., to plan a single module and stop. Its sessions are resumable, and it rereads the settings before each module, so that the user can change course while the campaign runs. Between approval gates, it runs autonomously: it runs and promotes the planner runs, scaffolds the Rust tree, prepares each wave, spawns one binder or reviewer per batch up to the approved parallelism, promotes completed waves, and keeps the campaign’s accounting, but binds and reviews no batch itself, and advances the shipping branch only with the user’s approval. When a campaign exposes a defect in Crustify or one of its tools, the assistant fixes it on a separate branch of that component, and before each wave it curates a shared agent memory, through which agents share lessons that need no code change. Planner. Planner runs execute planning one after another, each on a branch of its own. Each run brings the committed plan up to date and corrects what earlier runs decided, including Wavefront’s correction tables (§3.3), filing a report with the evidence for each correction. The assistant checks each run’s changes before promoting it, e.g., that no frozen module or started wave changed, and re-plans a changed scope or workload the same way, without re-planning landed work. 4
Evaluation
We evaluate Crustify on two campaigns that wrap the public APIs of libgit2 and ffmpeg, and answer the following research questions: RQ1 (Safety and ergonomics). How much unsafe code and how many raw pointers remain on the bindings’ public API, and how consistently do the bindings build on ffibox’s primitives rather than their own, compared with community-maintained bindings? RQ2 (Soundness evidence). How thoroughly do the bindings’ tests exercise Rust and C code under sanitizers and Miri, and what defects does review find, compared with communitymaintained bindings?
18
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen
RQ3 (Coverage and cost). How much of a repository-level public API does Crustify wrap, at what cost in money, tokens, and time? RQ4 (Dependency-ordered scheduling). What schedules does Wavefront produce, and how many of the dependency relations that casts and type-erased slots hide do agents correct? RQ5 (Ownership and lifecycle reasoning). Which ownership and lifecycle contracts do the agents derive for the pointers and types that they wrap, and how accurate are they?
4.1
Setup
Both campaigns target every non-deprecated item exported by their target’s public headers, i.e., libgit2’s and those of ffmpeg’s seven libraries. We count API coverage over these items except enums, whose constants the bindings import from the raw bindings (§3.4.3), over 165 types and 981 symbols for libgit2, and 210 and 1,128 for ffmpeg. They run with at most 2 types per type batch, which closes once its types’ declared fields reach 20, so that a type with 20 or more fields is scheduled in a batch of its own, and with at most 25 symbols and 500 lines of function bodies per symbol batch, where a longer symbol is scheduled in a batch of its own. Up to 16 batches run concurrently, and up to 32 in waves of small review batches, on a machine with an Intel Xeon CPU (144 hardware threads) and 1 TB of RAM. libgit2’s C code is built with Ubuntu clang 18.1.3 and ffmpeg’s with gcc 14.2 in a plain configuration, and with Ubuntu clang 18.1.3 and 20.1.2 respectively with ASan and UBSan, and with TSan, while the BorrowSanitizer configuration uses BorrowSanitizer’s own clang toolchain and compiler plugin. The coverage we report comes from a separate build of each C library, instrumented with LLVM’s source-based coverage (-fprofile-instr-generate -fcoverage-mapping) by clang 23, the LLVM release of the Rust nightly that instruments the bindings, so that one llvm-cov reads both languages. One developer supervised both campaigns, which ran concurrently, through the assistant, while most of each campaign ran autonomously. Each campaign ran its modules in sub-campaigns, i.e., groups of related modules, such as libgit2’s index or ffmpeg’s crypto modules, 9 in libgit2 and 8 in ffmpeg, including the foundational ones. The developer approved each campaign’s brief and checked the bindings between sub-campaigns, but did not intervene in the agents’ work. They occasionally corrected defects in Crustify’s prompts and tools, which then did not recur. Binders and reviewers mainly ran Claude Opus 5.5. GPT-6.1 Sol wrapped libgit2’s config and ffmpeg’s crypto, GPT-6 Astra wrapped most of libgit2’s first index wave and reviewed one ffmpeg batch, and Claude Fable 5.1 reviewed the early sub-campaigns; we stopped using the GPT models since their safety guardrails kept interrupting agents that write tests and reproducers for memorysafety violations. In 64 of the 106 review batches, the reviewer ran on a different model than the binders of its wave. Crustify’s prompts and gates were still being corrected while the campaigns ran. We coarsened review from one review batch per binding batch, most of which reported no UB, to one review wave per binding wave (§3.4.4), moved the regression gates from the assistant to the binders and reviewers, and added the workspace-wide Miri and BorrowSanitizer gates of §3.4.4.
Baselines. RQ1–RQ3 each compare the generated bindings with the community-maintained git2-rs [22] for libgit2, and ffmpeg-the-third [21] and ffmpeg-next [20] for ffmpeg, which their maintainers developed over several years. We measured each crate at its latest revision, against the C release it targets (libgit2 v1.9.6 and ffmpeg 9.0), measured coverage as in the campaigns, over the same C, and ran ffimap on each crate, with the seams that its own conventions name (Table 2).
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
19
Table 2. Generated versus community-maintained bindings. Baseline API coverage is an upper bound that counts every C item the crate’s source names, enums excluded as for ours; ours counts wrapped items only. libgit2’s C line coverage is over libgit2 with its bundled xdiff and llhttp, which both crates compile, against 45.95% over libgit2 alone (Table 4). Public functions include the methods of trait implementations. libgit2 Crustify
ffmpeg git2-rs
Crustify ffmpeg-the-third ffmpeg-next
API coverage (%) 41.9 ≤65.1 42.8 Tests 2,064 238 2,694 Rust line coverage (%) 98.92 60.59 99.07 C line coverage (%) 46.78 45.12 67.20 Public functions 2,663 2,719 2,637 unsafe by choice 42 (1.6%) 23 (0.8%) 11 (0.4%) with a raw pointer 2 (0.1%) 0 (0.0%) 16 (0.6%) safe, taking one 0 0 7 Types with a hand-written Drop 5 (1.3%) 52 (28.3%) 3 (0.9%) Types with a reference-forming trait 0 3 (1.6%) 0 Types storing a raw pointer 3 (0.8%) 70 (38.0%) 14 (4.2%)
4.2
≤18.7 47 11.34 6.77 2,222 9 (0.4%) 7 (0.3%) 6 15 (8.5%) 20 (11.3%) 48 (27.1%)
≤17.6 48 18.73 10.08 1,955 11 (0.6%) 13 (0.7%) 11 17 (10.6%) 26 (16.1%) 49 (30.4%)
RQ1: Safety and Ergonomics
Unsafe surface. Of the bindings’ 2,663 and 2,637 public functions, counting the methods of their trait implementations, 102 and 63 are unsafe, most of them not by the bindings’ choice: 55 and 45 implement ffibox’s lifecycle policies, e.g., CDrop::c_drop, which ffibox’s traits declare unsafe and only ffibox’s owners call, and 5 and 7 are seams, conversions that the type system does not check, across the boundary or between Rust types, e.g., from_raw. The remaining 42 and 11 (1.6% and 0.4% of the public functions) are unsafe by choice, mostly for obligations that no Rust type expresses. In libgit2, 35 set or read libgit2’s process-wide options and trace callback, which libgit2 does not synchronize and git2-rs also declares unsafe; 3 install backends implemented in C, e.g., RepositoryRef::set_refdb; and 2 clear or write an object database’s commit graph and multi-pack index, which no other thread may use meanwhile through any of the database’s owners. In ffmpeg, 2 mirror unsafe APIs of the standard library and memmap2, MaybeUninit::assume_init and Mmap::map, and 7 rely on what only C records, such as the allocation behind a frame’s plane pointers or a buffer pool’s allocator. The other 2 and 2 could be made safe, e.g., cpu::force_cpu_flags by checking the flags against those the CPU reports. Outside seams and ffibox’s traits, 2 and 16 public functions (0.1% and 0.6%) have a raw pointer in their signature: in libgit2, both take a pointer to a C type that the bindings do not wrap: Refdb::new a referencedatabase backend, which a later wave of its own module wraps (§3.3), and add_ssl_x509_cert an OpenSSL certificate, which another library owns; in ffmpeg, 14 store or return opaque user data, and two set a frame’s plane pointers. 0 and 7 of them are safe functions that take one. Over the 455 and 560 public functions that wrap a C function, ffibox’s policies aside, 39 and 5 (8.6% and 0.9%) are unsafe and 2 and 2 (0.4% and 0.4%) have a raw pointer in their signature; libgit2’s share of unsafe wrappers is higher than over all its public functions because every one of its 35 process-wide option and trace functions wraps a C function. Counting every public function, our libgit2 bindings declare a slightly smaller share unsafe than git2-rs (3.8% against 4.1%), and our ffmpeg bindings a smaller one than ffmpeg-the-third and ffmpeg-next (2.4%, against 3.7% and 5.7%), but most of the community crates’ unsafe functions are seams that hand a C pointer to or from the consumer, such as git2-rs’s Binding::from_raw,
20
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen
and few are unsafe by choice in any of the five (Table 2). A raw pointer in the signature of a safe function is sound when the function returns it, but not when it adopts a pointer that safe code later dereferences: neither libgit2 crate has such a function, and the 7 in our ffmpeg bindings store user-data pointers that neither ffmpeg nor the bindings dereference, whereas among the 11 of ffmpeg-next are safe constructors such as codec::video::RateIter::new, whose iterator dereferences the pointer, so that safe code can cause UB by passing any address. Behind this interface, unsafe blocks account for 16.6% and 16.6% of the bindings’ non-test code. This surface covers only what the bindings wrap, however: a consumer that calls one of the 487 and 607 public C functions that remain unwrapped passes it a wrapped object through a seam and an unsafe call. Wrapped types sit behind a pointer in 433 and 91 parameters and returns of these functions, most often libgit2’s git_repository (126 parameters) and ffmpeg’s AVFrame (42), and in 46 and 55 fields of unwrapped structs; another 15 and 49 parameters and returns hold one by value, which needs only a conversion. ffibox usage. Through the rustdoc aliases of §3.2 (Table 3), the bindings wrap 86.8% and 95.3% of the C functions that libgit2-sys and libavutil-sys declare; the functions they leave unwrapped serve the tests, which call C directly, and the ffmpeg bindings wrap 15 C functions that they reimplement rather than call, such as the static inline av_uuid_copy. Of the -sys structs that the bindings use, 93.2% and 97.7% are wrapped with a layout type, i.e., a type implementing CCell. ffibox carries most of the bindings’ types: 66.8% and 66.2% of their public types implement one of its traits, as layout types and their handles or as lifecycle policies, and most of the others are plain Rust values, such as enums of C constants and error types. The 34 libgit2 layout types that define_ctype! did not emit carry lifetimes, which the macro does not support (§3.4.3). Public signatures return ffibox owners in 197 and 126 positions and take them in 37 and 43, and a layout type that a signature names by value is a value type whose bytes Rust owns, such as Oid and Rational. Field accessors are methods of a handle rather than of a layout type: 213 and 201 read a field through a shared handle and 172 and 217 write one through an exclusive handle, so no accessor forms a reference to the C object; the other 8 and 3 stand for a C vtable’s free member, which a policy implements, for an operation that consumes its owner, or for storage that Rust owns. Only immutable globals occur in our campaigns, which the bindings expose as plain shared references, and no callback was forked by contract. Outside ffibox. The bindings bypass ffibox’s primitives in a few places. No standard owner (e.g., Box or Arc) holds a layout type, and no type implements a reference-forming trait such as Deref. Only 5 and 3 public types (1.3% and 0.9%) implement Drop by hand: guards over C state rather than C objects, such as libgit2’s Runtime, which shuts libgit2 down; owners of Rust storage that C reads, such as a write stream implemented in Rust; and collections that CVec cannot represent, a vector whose strings own memory and, in ffmpeg, arrays that C grows in place through a pointer and an int count lent by address. The community crates, by contrast, release C objects in hand-written Drop implementations in 28.3%, 8.5%, and 10.6% of their public types, and implement reference-forming traits in up to 16.1%. A type built on ffibox declares no raw pointer of its own: the pointer of the C object it manages sits inside a CBox, CArc, or CVec whose trait declares its lifecycle, or inside the C struct that a layout type covers. Counting the raw pointers, NonNulls, and AtomicPtrs that a crate declares in the fields of the structs it declares itself, 3 and 14 of our public types hold one (0.8% and 4.2%), against 38.0%, 27.1%, and 30.4% of the community crates’ (Table 2). Ours are, in libgit2, a view that borrows Rust strings, a write stream implemented in Rust, and a configuration identity that is never dereferenced, and in ffmpeg, C memory that no layout type covers: handles to a type-erased object, i.e., any C struct whose first member points to an AVClass, C’s iteration state, the in-place arrays and their views, a log record’s va_list, and two
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
21
Table 3. ffibox use in the bindings, over the C functions and structs of the wrapped library’s -sys crate. libgit2
ffmpeg
C functions wrapped / declared Used C structs with a layout type C fields with an accessor accessors, on shared / exclusive handles Public types on ffibox (%) Layout types: define_ctype! / by hand ffibox owners returned / takena
435 / 501 68 / 73 212 213 / 172 66.8 40 / 34 197 / 37
503 / 528 42 / 43 192 201 / 217 66.2 58 / 0 126 / 43
Hand-written Drop / raw-pointer types Used C structs without a layout type Unsanctioned references to layout typesa
5/5 5 22
3 / 19 1 25
a Positions in public signatures and fields.
blocks whose layout C keeps to itself. The C structs used without a layout type are libgit2’s, which the bindings reach before the waves that wrap them, and ffmpeg’s va_list. Outside the sanctioned receivers, public signatures and fields hold 22 and 25 references to a layout type: 16 and 20 are the second operand of a comparison, which the standard traits require, such as PartialEq::eq(&self, other: &Self), and most of the others are slices of value types that Rust holds, such as &[Oid]; none refers to memory that C holds, and these counts are lower bounds (§3.2). 4.3
RQ2: Soundness Evidence
Tests. Sanitizers and Miri detect UB only on the paths that tests execute, so the bindings’ tests and their coverage bound the evidence that the sanitizers provide. The bindings carry 2,064 and 2,694 tests in the four suites of §3.4.3 (Table 4), which together cover 98.92% and 99.07% of the bindings’ non-test Rust code, and 45.95% of libgit2’s C code and 67.20% of libavutil’s, the only ffmpeg library wrapped so far. Both languages are measured with LLVM’s source-based coverage from one toolchain, over the code under test only. At both campaign tips, every suite passes on the plain build and under ASan, UBSan, and TSan, and the workspace passes BorrowSanitizer, which skips the compile-fail suite and, in the libgit2 bindings, 16 tests: 11 whose inputs outlast the gate, 1 that hangs on a child process’s abort path, and 4 that would trigger BorrowSanitizer false positives. The community crates’ suites run no sanitizers and are much smaller: the generated bindings carry roughly an order of magnitude more tests than git2-rs and more than fifty times as many as the ffmpeg crates, and cover nearly all of their Rust code, against 61% for git2-rs and under 20% for the ffmpeg crates. On the C side, git2-rs’s suite reaches about as much of libgit2 as ours (45.12% against 46.78%, both over libgit2 with its bundled xdiff and llhttp), whereas the ffmpeg crates reach about a tenth of libavutil at most, against two thirds for our bindings (Table 2). Since Miri cannot execute foreign code, it runs only the 598 and 700 tests that make no FFI call (29.0% and 26.0% of the suites), all of which pass. Agentic review. The review waves filed 178 and 138 reports (Table 5), each with a fix; UB and equivalence defects also come with a reproducer. Three categories bear directly on safety. First, 6 and 4 defects are UB reachable from safe Rust, and 2 and 9 more are UB reachable through the unsafe API within its contract. By kind, they comprise 5 and 2 out-of-bounds accesses, 1 and 3 uses after free, 1 double free in ffmpeg, 1 null-pointer dereference in libgit2, and no aliasing violation; the others are 6 arithmetic overflows in ffmpeg’s C, a data race, and a misaligned slice. For instance,
22
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen
Table 4. Line coverage per test suite at the campaign tips, over the bindings’ non-test sources (Rust) and over libgit2 and libavutil, the only ffmpeg library wrapped so far (C). All is the union of all suites. libgit2
ffmpeg
Suite
Tests
Rust (%)
C (%)
Tests
Rust (%)
C (%)
Safe UB Unsafe UB Equivalence Unit
688 100 489 787
79.28 12.81 75.93 87.34
28.10 22.12 43.47 32.03
821 182 723 968
75.04 28.16 80.38 84.45
53.10 26.64 66.67 41.20
All
2,064
98.92
45.95
2,694
99.07
67.20
inserting a SHA-256 object ID into a SHA-1 repository’s tree builder through libgit2’s safe API made C write past a heap buffer, which the wrapper now refuses. Second, 7 and 8 reports concern the unsafe boundary: 3 and 6 made the public surface safer, removing 3 and 5 public unsafe functions and, in ffmpeg, 3 raw pointers, or closing unsound safe entry points, such as libgit2’s error setters, which now take the typed ErrorClass instead of a raw integer that safe code could set to any value, and 4 and 2 replaced internal raw-pointer code with safe helpers. Third, 11 and 8 corrected a /// # Safety obligation or a // SAFETY: justification, Send and Sync ones included. Review thus changed the public surface (§4.2) no more often than it corrected UB or safety obligations, since that surface was already small when the binders landed it. In addition, 6 and 4 reports are behavioral divergences from C. Most remaining reports concern coding conventions and miscellaneous fixes to documentation and tests. Review fills the coverage gaps it finds with tests rather than reports, and its fixes come with regression tests: review batches added 153 and 165 tests, 7.4% and 6.1% of the suites, which raise the suites’ coverage by 1.55 and 1.09 percentage points on the Rust side and by 0.52 and 0.42 points on the C side, and a single suite by up to 3.82 points, since the binding batches’ own tests already reach nearly all of the bindings. Review of a community crate. We also ran Crustify’s reviewers, with the UB, contract, and equivalence objectives and the same instruments, on git2-rs 0.21.0. In about 1.4 hours of wall-clock time and $44, covering 19 of its 64 source files, they found 25 defects that cause UB reachable from safe code, each confirmed by a reproducer: 15 under ASan or UBSan, 4 under TSan, 4 under BorrowSanitizer, and 2 under Miri, none of which git2-rs’s CI can detect, since it runs neither sanitizers nor Miri. We are disclosing them to its maintainers and withhold their details until they are fixed. 4.4
RQ3: Coverage and Cost
API coverage. Crustify and one developer wrapped 480 of libgit2’s 1,146 public items (41.9%), i.e., 69 of its 165 types (41.8%) and 411 of its 981 symbols (41.9%), and 573 of ffmpeg’s 1,338 public items (42.8%, counted over ffmpeg as a whole), i.e., 57 of its 210 types (27.1%) and 516 of its 1,128 symbols (45.7%). Its binding batches inserted 165,289 and 101,056 lines of Rust, tests included, of which 17,571 (10.6%) and 18,681 (18.5%) are non-test code. We stopped both campaigns before they covered the entire API mainly to spare tokens and share our findings. Even so, the ffmpeg bindings cover more than twice as much of the public API as either community crate, even at their upper bounds, whereas git2-rs’s upper bound, 65.1%, exceeds our 41.9%, so git2-rs may cover more of libgit2 (Table 2).
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
23
Table 5. Review reports per category. UB (safe) and UB (unsafe) are reachable from the safe API and from the unsafe API within its contract. Category
libgit2
ffmpeg
UB (safe) UB (unsafe) Residual unsafe or raw types Safety obligations Equivalence Othera
6 2 7 11 6 146
4 9 8 8 4 105
All reports
178
138
a Missing lifecycle routines or owners, internal defects,
deviations from the coding conventions, miscellaneous fixes, and reports on the ownership and lifecycle records that agents submitted to Wavefront (33 and 34).
Cost. The libgit2 campaign used about 5.1 billion tokens ($2,308) and the ffmpeg campaign so far about 4.5 billion tokens ($2,120). Agents ran for 51.9 and 58.9 hours of wall-clock time, running the batches of each wave concurrently (§4.5); run one batch at a time, they would have taken 112 and 159 hours, i.e., 2.2 and 2.7 times as long. The planners, binders, and reviewers account for $1,823 and $1,678 of the cost, and the assistant for $485 and at least $442: $249 and $272 to orchestrate the campaigns, $149 and $31 to plan them, and $86 and $139 to answer the developer’s queries. A type cost $4.63 to wrap on average, between $0.77 and $24.34, largely independently of the number of fields it wraps, whereas the cost of a symbol grows with the C code it wraps, at $0.89 per symbol or about $52 per thousand lines of C; review accounts for 43% and 52% of the agents’ cost (Appendix B). 4.5
RQ4: Dependency-Ordered Scheduling
Graphs and schedules. Both campaigns are scheduled from public-signature graphs of 7,526 and 5,651 nodes. Wavefront orders the campaigns’ 95 and 160 modules into 9 and 10 module layers and schedules their 1,144 and 1,337 items, i.e., the public APIs of §4.4 but for 2 and 1 macros, which the bindings reach through the items that use them, in 204 and 281 waves of up to 5 and 9 batches each; since independent modules run concurrently, the longest chain of dependent waves spans 10 and 12 waves. Of these, 67 and 98 waves hold the items that the campaigns have landed so far, under an earlier schedule. Cycles are rare: 29 and 11 of the nodes (0.39% and 0.19%) lie on a cycle, and Wavefront cuts 22 and 6 edges, i.e., 0.30% and 0.18% of all edges, to break them, and 22 and 7 dependencies between modules. In the implementation graphs, which also follow function bodies, 3.4% and 1.5% of the nodes lie on cycles, the largest of which spans 135 nodes and 5,147 lines of C in libgit2. Pipelines that condense cycles into single units (§6) would translate this cycle as one unit, whereas Wavefront’s unit is always a single item. Wavefront’s one-time analysis of each target took about 17 and 24 minutes (Appendix D). Concurrency. The campaigns landed these items in 34 and 26 waves, 19 and 14 of which ran batches of several modules concurrently. A wave ran between 1 and 12 and between 1 and 15 batches at once, 2 and 2 at the median, and up to 32 batches ran at once across the campaign. Since waves ran one after the other, a single batch ran for 61% and 58% of the time; §4.4 reports the wall-clock time that this concurrency saved.
24
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen
Corrections. The planners corrected the relations that casts and type-erased slots hide (D2) in 119 and 126 rows of the campaigns’ current plans: 80 and 91 add a relation that the extraction missed, i.e., a type that a void * parameter is read as or a base–derived pair, and 39 and 35 drop a spurious one, e.g., a slot that one file reuses for an unrelated type. In ffmpeg, all 68 added parameter rows resolve a logging or option context, a void * to any struct whose first member points to an AVClass: the first waves, planned without these rows, bound such contexts as void * in unsafe functions, and the re-planned waves bind them as typed handles, e.g., LogContext. libgit2’s planners also named 4 macros as the generators of 41 types, such as its GIT_HASHMAP_STRUCT family. Each row was reviewed twice: by the later planner runs, which re-check what earlier ones decided (§3.5), and by the developer before approving the plan. The later runs kept 67 of the first run’s 67 rows in libgit2 and added 52, and kept 68 of 69 in ffmpeg, dropped one, and added 58, each with a report. 4.6
RQ5: Ownership and Lifecycle Reasoning
Pointer contracts. The agents recorded the ownership of 1,262 and 897 pointers in fields, parameters, and returns: 145 and 118 own their pointee and 1,130 and 791 borrow it, 73% and 82% of the latter for another argument’s lifetime; 43% and 46% are mutable and 34% and 42% nullable, and 270 and 209 point to strings and 47 and 160 to other arrays. Lifecycle contracts. The agents also identified 150 and 100 constructors, 54 and 20 destructors, 19 and 14 cloners, 7 and 2 reference counts, and 2 and 3 lock-protected fields. Review of the contracts. Reviewers re-check the records of every item they review (§3.4.4), and every landed wave was reviewed. They filed 33 and 34 reports on the records (Table 5), each with the evidence, which we classified against the changes they made to the ownership store. Review found 3 and 11 false positives, i.e., wrong pointer records (0.2% and 1.2% of the positions), such as a nullable out-parameter recorded as non-null or a retained borrow recorded as lasting only the call, and no wrong lifecycle role. It found more false negatives: 42 and 35 positions (3.3% and 3.9%) of functions that binders had not recorded, and 24 and 14 lifecycle roles (10.3% and 10.1%), mostly constructors and cloners. The other reports refine the records’ notes, e.g., the conditions under which a call is UB. These counts bound the agents’ errors on the landed waves from below: review is not exhaustive, so they do not establish that the remaining records are correct. Appendix E compares the records with static ownership inference. 5
Discussion
Threats to validity. The models we used were likely trained on the community-maintained bindings that we compare against, and may draw on their design. They cannot copy them, however: Crustify’s bindings are built from ffibox, which did not exist before this work, and 66.8% and 66.2% of their public types implement ffibox’s traits (Table 3). Moreover, the ffmpeg bindings cover more than twice as much of the public API as either community crate, so at least half of the items they wrap have no counterpart in either crate; for libgit2, this argument applies only to the representation. Crustify’s prompts and gates changed while the campaigns ran, and one developer supervised both (§4.1): earlier waves thus ran under less strict rules than later ones, and since we did not run a campaign without the developer, we do not measure how much their checks contributed. Each batch of the reported campaigns ran once. Since LLM agents are not deterministic, repeated runs of the same batch may produce different bindings, tests, and review findings. We therefore report the reproducible artifacts of one campaign per target rather than averages over runs. Finally, Crustify’s safety and soundness evidence is empirical and therefore incomplete. Sanitizers and Miri detect UB only on the paths that tests execute, so the absence of reports is bounded by the test coverage that we measure; ffimap is a linter rather than a verifier
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
25
(§3.2), so its counts of references into C memory and of primitives written outside ffibox may miss sites; and review finds defects but does not show their absence. The defects that we report are thus a lower bound on the defects in the bindings. Future work. Safe bindings are also needed when a production codebase is ported to Rust incrementally, subsystem by subsystem: until the port completes, many pointers cross the FFI boundary between ported and unported subsystems, in both directions. Since Crustify already decomposes a target into modules and schedules them in dependency order, we plan to extend it to porting, with safe bindings at each boundary between ported and unported code. C++ libraries have safe bindings too, e.g., rusty_v8 [54] for V8, and we plan to support C++ targets next. C++ carries ownership and lifecycles in smart pointers and constructors, but its borrowed pointers and references still carry no lifetime, so their contracts must be inferred as for C. Finally, ffibox does not yet represent two collections that the bindings implement by hand (§4.2): vectors whose elements own memory, and arrays that C grows in place through a pointer and a count lent by address. 6
Related Work
FFI primitives. Hand-written binding crates, e.g., git2-rs [22], rusqlite [50], curl-rust [15], and ffmpeg-next [20], encode a C library’s ownership conventions type by type, with hand-written Drop implementations and references into C-owned memory, in ffmpeg-next even &mut [u8] over frame buffers that C may share. aws-lc-rs [5] factors its owners into a generic pointer type, and ffmpeg-the-third [21] adds lifetime-bound Ref and Mut handles for some types, but both remain hand-written per type. foreign-types [19], used by rust-openssl, fixes one destructor per type with a macro and hands out borrowed objects as &FooRef, and Rust for Linux [36, 52] exposes kernel objects as &T over an UnsafeCell-based Opaque<T>; such references can cause UB at FFI boundaries [43]. Closest to ffibox, Omniglot [56, 57] hands out a reference into foreign memory only after run-time validation and until the next foreign call, but targets untrusted libraries, pays for isolation at run time, and does not track the lifecycle of individual foreign objects. Systems that isolate foreign code [33, 34, 49] or detect cross-language memory-management errors [37] do not let safe Rust own or borrow C objects in place. ffibox instead never forms a reference to C-owned memory, names lifecycle routines through policy types, and withholds Send and Sync unless a binding argues for them, at no run-time cost. Type-dependency ordering. Repository-level translation pipelines order their units over call graphs [45], types before methods [27], or one graph of types, globals, and functions [14, 74, 77], as Wavefront does. On cycles, RustMap [8], IDEAS, and PtrTrans condense each SCC into one translation unit, SACTOR rejects cyclic inputs, and C2SaferRust and AlphaTrans break cycles where their traversal does. Wavefront instead splits cycles with a weighted form of the vertex ordering of Eades et al.’s feedback-arc-set heuristic [17] and layers the graph by longest path [66], so that a wave’s batches can be wrapped concurrently, and it keeps lifecycle routines as nodes of their own, whereas PtrTrans removes deallocation functions from its graph. Indexers such as clangd, Joern, and CodeQL [4, 40, 71] expose the facts of such a graph, not the graph. Ownership inference. Ownership inference for C pointers mainly serves translation into safe Rust. Laertes [18] rewrites raw pointers in C2Rust output using feedback from the borrow checker; Crown [75] infers ownership with a SAT-based analysis, but generates no constraints when a library wraps its allocator behind function pointers; and PtrTrans [74] derives annotations from fixed rules that label each pointer as owning or borrowing and every pointer as nullable. LLM-based approaches infer the Checked C bounds of pointers [44] or the functions that allocate or free memory [61], but not borrows, conditional ownership, cloners, or reference counts. Closest to
26
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen
Crustify, &inator [11] infers an ownership and borrowing discipline for a C program’s interface with a whole-program SMT encoding, but chooses one Rust type per pointer for a rewrite into Rust-owned memory rather than for bindings over unchanged C, and does not yet support function pointers, unions, or multi-threaded programs (Appendix E). Safety measurement. cargo-geiger [9], the unsafe metric of PtrTrans, counts unsafe items and expressions with a parser, and cargo-scan [78] reports effects, such as FFI calls, that reach a crate’s pub functions; Astrauskas et al. [3] note that pub does not distinguish visibility within a crate from visibility to other crates. C-to-Rust translators count raw pointers or unsafe lines or tokens over the whole program [18, 38, 45, 62, 75, 77]. ffimap instead counts what a binding exposes to its consumers. Unlike either tool, it also classifies each raw pointer by its position, visibility, and the safety of its function, and treats the types imported from a -sys crate as shared with C: it finds the raw pointers that a binding’s types hold through them, and the C structs that a binding uses raw rather than through a layout type (Appendix C). Multi-agent systems for C-to-Rust tasks. Most LLM-based, project-level C-to-Rust systems drive one model through a fixed pipeline of translation and compiler-guided repair [8, 45, 62, 69, 74, 77]. ReCodeAgent [28] runs its analyzer, planner, translator, and validator agents as separate sessions, and Schesch and Ernst [55] let a coding agent delegate subtasks to subagents; neither validates with a model other than the translator’s, and both process units one at a time, whereas Crustify’s reviewers mostly ran on a different model than the binders (§4.1) and a wave’s batches run concurrently. None of these systems generates safe bindings to unchanged C: FFI serves them only as a verification scaffold [77] or as an exported C interface [55]. Apart from Schesch and Ernst’s equivalence tests, their validation relies mostly on existing tests (§1), and none measures line coverage of the C and the Rust code in the same FFI-linked run to guide test generation, or feeds sanitizer and Miri reports back to its agents. 7
Conclusion
We presented Crustify, an agentic co-translator that generates safe Rust bindings for repositorylevel C libraries, designed to be sound and ergonomic. Its agents bind dependency-ordered batches concurrently with ffibox’s primitives, derive the ownership and lifecycle contracts of the code they wrap, which Wavefront stores, and check the bindings with coverage-guided tests under sanitizers and Miri and with independent review, while ffimap maps their unsafe surface. On libgit2 and ffmpeg, with one developer in the loop, Crustify wrapped 41.9% and 42.8% of their public APIs; on the interface that consumers are expected to use, only 1.6% and 0.4% of their public functions are unsafe. We plan to extend Crustify to C++ and to incremental ports. Data-Availability Statement We will release as open source the Crustify harness, including its agent prompts and skills, the ffibox, Wavefront, and ffimap tools, the safe bindings generated for libgit2 and ffmpeg, and the campaign data from which our evaluation is computed, i.e., schedules, ownership stores, review reports, ffimap scans, cost records, and analysis scripts (https://github.com/crustify-rs). Its main limitation is that rerunning a campaign requires access to the commercial LLMs we used, costs about $2,308 for libgit2 and $2,120 for ffmpeg, and, since LLM agents are not deterministic, yields bindings that differ from ours; the released campaign data and generated bindings, however, allow every figure in the evaluation to be reproduced without rerunning the agents.
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
27
Use of Generative AI The LLM agents that generate and review the bindings are part of the research method (§3.5). In addition, we used LLMs to assist in editing and revising the text of this paper, checking its claims about prior work against the cited papers, and developing the tools it presents; the authors reviewed all such content and take full responsibility for it. Acknowledgments This material is based upon work supported by the Defense Advanced Research Projects Agency under Agreement HR00112590134. References [1] Juan Altmayer Pizzorno and Emery D. Berger. 2025. CoverUp: Effective High Coverage Test Generation for Python. Proceedings of the ACM on Software Engineering 2, FSE (2025), 2897–2919. doi:10.1145/3729398 [2] Anthropic. 2026. Claude Code: How Claude Code works. https://code.claude.com/docs/en/how-claude-code-works. Accessed 2026-10-13. [3] Vytautas Astrauskas, Christoph Matheja, Federico Poli, Peter Müller, and Alexander J. Summers. 2020. How Do Programmers Use Unsafe Rust? Proceedings of the ACM on Programming Languages 4, OOPSLA (2020), 136:1–136:27. doi:10.1145/3428204 [4] Pavel Avgustinov, Oege de Moor, Michael Peyton Jones, and Max Schäfer. 2016. QL: Object-oriented Queries on Relational Data. In 30th European Conference on Object-Oriented Programming (ECOOP 2016) (Leibniz International Proceedings in Informatics (LIPIcs), Vol. 56). Schloss Dagstuhl – Leibniz-Zentrum für Informatik, 2:1–2:25. doi:10.4230/ LIPIcs.ECOOP.2016.2 [5] aws-lc-rs contributors. 2026. aws-lc-rs: A cryptographic library using AWS-LC for its cryptographic operations. https://github.com/aws/aws-lc-rs. [6] Yubo Bai and Tapti Palit. 2025. RustAssure: Differential Symbolic Testing for LLM-Transpiled C-to-Rust Code. In 2025 40th IEEE/ACM International Conference on Automated Software Engineering (ASE). IEEE, 534–546. doi:10.1109/ ase63991.2025.00051 [7] C2Rust contributors. 2026. C2Rust: Migrate C code to Rust. https://github.com/immunant/c2rust. [8] Xuemeng Cai, Jiakun Liu, Xiping Huang, Yijun Yu, Haitao Wu, Chunmiao Li, Bo Wang, Imam Nur Bani Yusuf, and Lingxiao Jiang. 2025. RustMap: Towards Project-Scale C-to-Rust Migration via Program Analysis and LLM. In Engineering of Complex Computer Systems (ICECCS 2025) (Lecture Notes in Computer Science, Vol. 15746). Springer Nature Switzerland, 283–302. doi:10.1007/978-3-032-00828-2_16 [9] cargo-geiger contributors. 2026. cargo-geiger: Detects usage of unsafe Rust in a Rust crate and its dependencies. https://github.com/geiger-rs/cargo-geiger. [10] cargo-scan contributors. 2026. cargo-scan: A tool for auditing Rust crates. https://github.com/PLSysSec/cargo-scan. [11] Victor Chen, Ayden Coughlin, and Michael D. Bond. 2026. &inator: Correct, Precise C-to-Rust Interface Translation. Proceedings of the ACM on Programming Languages 10, PLDI (2026), 580–603. doi:10.1145/3808270 [12] Xinyun Chen, Maxwell Lin, Nathanael Schärli, and Denny Zhou. 2024. Teaching Large Language Models to Self-Debug. In The Twelfth International Conference on Learning Representations (ICLR). 8746–8825. [13] Clippy contributors. 2026. Clippy: A collection of lints to catch common mistakes and improve your Rust code. https://github.com/rust-lang/rust-clippy. [14] Cory Cornelius, Marcela S Melara, Weilin Xu, Marius Arvinte, Marius Momeu, Jingxuan He, Koushik Sen, and Dawn Song. 2026. IDEAS: C-to-Rust Translation Using Improved Large Language Model Decoding and Automated Equivalence Testing. IEEE Security & Privacy 24, 5 (2026), 28–37. doi:10.1109/MSEC.2026.3710126 [15] curl-rust contributors. 2026. curl-rust: Rust bindings to libcurl. https://github.com/sagebind/curl-rust. [16] Yufeng Du, Minyang Tian, Srikanth Ronanki, Subendhu Rongali, Sravan Babu Bodapati, Aram Galstyan, Azton Wells, Roy Schwartz, Eliu A. Huerta, and Hao Peng. 2025. Context Length Alone Hurts LLM Performance Despite Perfect Retrieval. In Findings of the Association for Computational Linguistics: EMNLP 2025. 23281–23298. doi:10.18653/v1/2025. findings-emnlp.1264 [17] Peter Eades, Xuemin Lin, and W. F. Smyth. 1993. A fast and effective heuristic for the feedback arc set problem. Inform. Process. Lett. 47, 6 (1993), 319–323. doi:10.1016/0020-0190(93)90079-O [18] Mehmet Emre, Ryan Schroeder, Kyle Dewey, and Ben Hardekopf. 2021. Translating C to safer Rust. Proceedings of the ACM on Programming Languages 5, OOPSLA (2021), 1–29. doi:10.1145/3485498 [19] Steven Fackler. 2026. foreign-types: A framework for Rust wrappers over C APIs. https://github.com/sfackler/foreigntypes.
28
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen
[20] ffmpeg-next contributors. 2026. ffmpeg-next: Safe FFmpeg wrapper for Rust. https://github.com/zmwangx/rust-ffmpeg. [21] ffmpeg-the-third contributors. 2026. ffmpeg-the-third: Safe FFmpeg wrapper for Rust. https://github.com/shssoichiro/ ffmpeg-the-third. [22] git2-rs contributors. 2026. git2-rs: libgit2 bindings for Rust. https://github.com/rust-lang/git2-rs. [23] GitHub. 2026. CodeQL documentation. https://codeql.github.com/docs/. Accessed 2026-10-13. [24] Jaemin Hong and Sukyoung Ryu. 2025. Type-migrating C-to-Rust translation using a large language model. Empirical Software Engineering 30, 1 (2025), 3. doi:10.1007/s10664-024-10573-2 [25] Cheng-Ping Hsieh, Simeng Sun, Samuel Kriman, Shantanu Acharya, Dima Rekesh, Fei Jia, Yang Zhang, and Boris Ginsburg. 2024. RULER: What’s the Real Context Size of Your Long-Context Language Models?. In First Conference on Language Modeling (COLM). https://openreview.net/forum?id=kIoBbc76Sy [26] Jie Huang, Xinyun Chen, Swaroop Mishra, Huaixiu Steven Zheng, Adams Wei Yu, Xinying Song, and Denny Zhou. 2024. Large Language Models Cannot Self-Correct Reasoning Yet. In The Twelfth International Conference on Learning Representations (ICLR). 32808–32824. [27] Ali Reza Ibrahimzada, Kaiyao Ke, Mrigank Pawagi, Muhammad Salman Abid, Rangeet Pan, Saurabh Sinha, and Reyhaneh Jabbarvand. 2025. AlphaTrans: A Neuro-Symbolic Compositional Approach for Repository-Level Code Translation and Validation. Proceedings of the ACM on Software Engineering 2, FSE (2025), 2454–2476. doi:10.1145/ 3729379 [28] Ali Reza Ibrahimzada, Brandon Paulsen, Daniel Kroening, and Reyhaneh Jabbarvand. 2026. ReCodeAgent: A Multiagent Workflow for Language-Agnostic Translation and Validation of Large-Scale Repositories. In Proceedings of the 41st IEEE/ACM International Conference on Automated Software Engineering (ASE ’26). ACM, 2409–2421. doi:10.1145/ 3832783.3837486 [29] Ralf Jung, Hoang-Hai Dang, Jeehoon Kang, and Derek Dreyer. 2020. Stacked borrows: an aliasing model for Rust. Proceedings of the ACM on Programming Languages 4, POPL (2020), 41:1–41:32. doi:10.1145/3371109 [30] Ralf Jung, Jacques-Henri Jourdan, Robbert Krebbers, and Derek Dreyer. 2018. RustBelt: securing the foundations of the Rust programming language. Proceedings of the ACM on Programming Languages 2, POPL (2018), 66:1–66:34. doi:10.1145/3158154 [31] Ralf Jung, Benjamin Kimock, Christian Poveda, Eduardo Sánchez Muñoz, Oli Scherer, and Qian Wang. 2026. Miri: Practical Undefined Behavior Detection for Rust. Proceedings of the ACM on Programming Languages 10, POPL (2026), 1383–1411. doi:10.1145/3776690 [32] Tushar Khot, Harsh Trivedi, Matthew Finlayson, Yao Fu, Kyle Richardson, Peter Clark, and Ashish Sabharwal. 2023. Decomposed Prompting: A Modular Approach for Solving Complex Tasks. In The Eleventh International Conference on Learning Representations (ICLR). https://openreview.net/forum?id=_nGgzQjzaRy [33] Paul Kirth, Mitchel Dickerson, Stephen Crane, Per Larsen, Adrian Dabrowski, David Gens, Yeoul Na, Stijn Volckaert, and Michael Franz. 2022. PKRU-Safe: Automatically Locking Down the Heap Between Safe and Unsafe Languages. In Proceedings of the Seventeenth European Conference on Computer Systems (EuroSys ’22). ACM, 132–148. doi:10.1145/ 3492321.3519582 [34] Benjamin Lamowski, Carsten Weinhold, Adam Lackorzynski, and Hermann Härtig. 2017. Sandcrust: Automatic Sandboxing of Unsafe Components in Rust. In Proceedings of the 9th Workshop on Programming Languages and Operating Systems (PLOS ’17). ACM, 51–57. doi:10.1145/3144555.3144562 [35] Caroline Lemieux, Jeevana Priya Inala, Shuvendu K. Lahiri, and Siddhartha Sen. 2023. CodaMosa: Escaping Coverage Plateaus in Test Generation with Pre-trained Large Language Models. In 2023 IEEE/ACM 45th International Conference on Software Engineering (ICSE). IEEE, 919–931. doi:10.1109/icse48619.2023.00085 [36] Hongyu Li, Liwei Guo, Yexuan Yang, Shangguang Wang, and Mengwei Xu. 2024. An Empirical Study of Rust-for-Linux: The Success, Dissatisfaction, and Compromise. In 2024 USENIX Annual Technical Conference (USENIX ATC 24). USENIX Association, 425–443. [37] Zhuohua Li, Jincheng Wang, Mingshen Sun, and John C. S. Lui. 2022. Detecting Cross-language Memory Management Issues in Rust. In Computer Security – ESORICS 2022 (Lecture Notes in Computer Science, Vol. 13556). Springer, 680–700. doi:10.1007/978-3-031-17143-7_33 [38] Michael Ling, Yijun Yu, Haitao Wu, Yuan Wang, James R. Cordy, and Ahmed E. Hassan. 2022. In Rust We Trust: A Transpiler from Unsafe C to Safer Rust. In Proceedings of the ACM/IEEE 44th International Conference on Software Engineering: Companion Proceedings (ICSE-Companion ’22). ACM, 354–355. doi:10.1145/3510454.3528640 [39] Nelson F. Liu, Kevin Lin, John Hewitt, Ashwin Paranjape, Michele Bevilacqua, Fabio Petroni, and Percy Liang. 2024. Lost in the Middle: How Language Models Use Long Contexts. Transactions of the Association for Computational Linguistics 12 (2024), 157–173. doi:10.1162/tacl_a_00638 [40] LLVM Project. 2026. clangd: C/C++ language server. https://clangd.llvm.org. [41] LLVM Project. 2026. UndefinedBehaviorSanitizer. Clang documentation, https://clang.llvm.org/docs/ UndefinedBehaviorSanitizer.html.
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
29
[42] Ian McCormack, Oliver Braunsdorf, Johannes Kinder, Jonathan Aldrich, and Joshua Sunshine. 2025. BorrowSanitizer (Extended Abstract). Rust Verification Workshop (RW 2025), https://plai.ifi.lmu.de/publications/rw25-borrowsanitizer. pdf. Software at https://borrowsanitizer.com. Accessed 2026-10-13. [43] Ian McCormack, Joshua Sunshine, and Jonathan Aldrich. 2025. A Study of Undefined Behavior Across Foreign Function Boundaries in Rust Libraries. In 2025 IEEE/ACM 47th International Conference on Software Engineering (ICSE). IEEE, 2075–2086. doi:10.1109/icse55347.2025.00167 [44] Nausheen Mohammed, Akash Lal, Aseem Rastogi, Rahul Sharma, and Subhajit Roy. 2025. LLM Assistance for Memory Safety. In 2025 IEEE/ACM 47th International Conference on Software Engineering (ICSE). IEEE, 1717–1728. doi:10.1109/ICSE55347.2025.00023 [45] Vikram Nitin, Rahul Krishna, Luiz Lemos do Valle, and Baishakhi Ray. 2026. C2SaferRust: Transforming C Projects Into Safer Rust With NeuroSymbolic Techniques. IEEE Transactions on Software Engineering 52, 2 (2026), 618–630. doi:10.1109/tse.2025.3641486 [46] Theo X. Olausson, Jeevana Priya Inala, Chenglong Wang, Jianfeng Gao, and Armando Solar-Lezama. 2024. Is SelfRepair a Silver Bullet for Code Generation?. In The Twelfth International Conference on Learning Representations (ICLR). 36545–36593. [47] OpenAI. 2026. Codex CLI. https://github.com/openai/codex. Accessed 2026-10-13. [48] Arjun Panickssery, Samuel R. Bowman, and Shi Feng. 2024. LLM Evaluators Recognize and Favor Their Own Generations. In Advances in Neural Information Processing Systems 37 (NeurIPS 2024). 68772–68802. doi:10.52202/0790172197 [49] Elijah Rivera, Samuel Mergendahl, Howard Shrobe, Hamed Okhravi, and Nathan Burow. 2021. Keeping Safe Rust Safe with Galeed. In Annual Computer Security Applications Conference (ACSAC ’21). ACM, 824–836. doi:10.1145/3485832. 3485903 [50] rusqlite contributors. 2026. rusqlite: Ergonomic bindings to SQLite for Rust. https://github.com/rusqlite/rusqlite. [51] rust-bindgen contributors. 2026. rust-bindgen: Automatically generates Rust FFI bindings to C (and some C++) libraries. https://github.com/rust-lang/rust-bindgen. [52] Rust for Linux contributors. 2026. The kernel crate. https://rust.docs.kernel.org/kernel/. [53] rust-openssl contributors. 2026. rust-openssl: OpenSSL bindings for Rust. https://github.com/rust-openssl/rustopenssl. [54] rusty_v8 contributors. 2026. rusty_v8: Rust bindings for the V8 JavaScript engine. https://github.com/denoland/rusty_ v8. [55] Benedikt Schesch and Michael D. Ernst. 2026. Agentic C to Rust Translation. IEEE Security & Privacy 24, 5 (2026), 18–27. doi:10.1109/MSEC.2026.3715863 [56] Leon Schuermann, Arun Thomas, and Amit Levy. 2023. Encapsulated Functions: Fortifying Rust’s FFI in Embedded Systems. In Proceedings of the 1st Workshop on Kernel Isolation, Safety and Verification (KISV ’23). ACM, 41–48. doi:10.1145/3625275.3625397 [57] Leon Schuermann, Jack Toubes, Tyler Potyondy, Pat Pannuto, Mae Milano, and Amit Levy. 2025. Building Bridges: Safe Interactions with Foreign Languages through Omniglot. In 19th USENIX Symposium on Operating Systems Design and Implementation (OSDI 25). USENIX Association, 595–613. [58] Konstantin Serebryany, Derek Bruening, Alexander Potapenko, and Dmitry Vyukov. 2012. AddressSanitizer: A Fast Address Sanity Checker. In 2012 USENIX Annual Technical Conference (USENIX ATC 12). USENIX Association, 309–318. [59] Konstantin Serebryany and Timur Iskhodzhanov. 2009. ThreadSanitizer: data race detection in practice. In Proceedings of the Workshop on Binary Instrumentation and Applications (WBIA ’09). ACM, 62–71. doi:10.1145/1791194.1791203 [60] Konstantin Serebryany, Alexander Potapenko, Timur Iskhodzhanov, and Dmitriy Vyukov. 2012. Dynamic Race Detection with LLVM Compiler. In Runtime Verification (RV 2011) (Lecture Notes in Computer Science, Vol. 7186). Springer, 110–114. doi:10.1007/978-3-642-29860-8_9 [61] Ekaterina Shemetova, Ivan Smirnov, Anton Alekseev, Ilya Shenbin, Alexey Rukhovich, Sergey Nikolenko, Vadim Lomshakov, and Irina Piontkovskaya. 2025. LAMeD: LLM-generated Annotations for Memory Leak Detection. In Proceedings of the 29th International Conference on Evaluation and Assessment in Software Engineering (EASE ’25). ACM, 1024–1034. doi:10.1145/3756681.3756999 [62] Momoko Shiraishi, Yinzhi Cao, and Takahiro Shinagawa. 2026. SmartC2Rust: Iterative, Feedback-Driven C-toRust Translation via Large Language Models for Safety and Equivalence. In Proceedings of the 2026 IEEE/ACM 48th International Conference on Software Engineering (ICSE ’26). ACM, 980–991. doi:10.1145/3744916.3773259 [63] Michael Siff, Satish Chandra, Thomas Ball, Krishna Kunchithapadam, and Thomas Reps. 1999. Coping with Type Casts in C. In Software Engineering — ESEC/FSE ’99 (Lecture Notes in Computer Science, Vol. 1687). Springer Berlin Heidelberg, 180–198. doi:10.1007/3-540-48166-4_12
30
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen
[64] Akshit Sinha, Arvindh Arun, Shashwat Goel, Steffen Staab, and Jonas Geiping. 2026. The Illusion of Diminishing Returns: Measuring Long Horizon Execution in LLMs. In The Fourteenth International Conference on Learning Representations (ICLR). https://proceedings.iclr.cc/paper_files/paper/2026/hash/3b4e1336f775c3dba16ebbb8d2afd258-AbstractConference.html [65] Evgeniy Stepanov and Konstantin Serebryany. 2015. MemorySanitizer: Fast detector of uninitialized memory use in C++. In 2015 IEEE/ACM International Symposium on Code Generation and Optimization (CGO). IEEE, 46–55. doi:10. 1109/cgo.2015.7054186 [66] Kozo Sugiyama, Shojiro Tagawa, and Mitsuhiko Toda. 1981. Methods for Visual Understanding of Hierarchical System Structures. IEEE Transactions on Systems, Man, and Cybernetics 11, 2 (1981), 109–125. doi:10.1109/TSMC.1981.4308636 [67] Robert Tarjan. 1972. Depth-First Search and Linear Graph Algorithms. SIAM J. Comput. 1, 2 (1972), 146–160. doi:10.1137/0201010 [68] Neven Villani, Johannes Hostert, Derek Dreyer, and Ralf Jung. 2025. Tree Borrows. Proceedings of the ACM on Programming Languages 9, PLDI (2025), 1019–1042. doi:10.1145/3735592 [69] Chaofan Wang, Tingrui Yu, Beijun Shen, Jie Wang, Dong Chen, Wenrui Zhang, Yuling Shi, Chen Xie, and Xiaodong Gu. 2026. EvoC2Rust: A Skeleton-guided Framework for Project-Level C-to-Rust Translation. In Proceedings of the IEEE/ACM 48th International Conference on Software Engineering: Software Engineering in Practice (ICSE-SEIP ’26). ACM, 94–105. doi:10.1145/3786583.3786856 [70] Xiafa Wu and Brian Demsky. 2025. GenC2Rust: Towards Generating Generic Rust Code from C. In 2025 IEEE/ACM 47th International Conference on Software Engineering (ICSE). IEEE, 90–102. doi:10.1109/ICSE55347.2025.00127 [71] Fabian Yamaguchi, Nico Golde, Daniel Arp, and Konrad Rieck. 2014. Modeling and Discovering Vulnerabilities with Code Property Graphs. In 2014 IEEE Symposium on Security and Privacy. IEEE, 590–604. doi:10.1109/sp.2014.44 [72] John Yang, Carlos E. Jimenez, Alexander Wettig, Kilian Lieret, Shunyu Yao, Karthik Narasimhan, and Ofir Press. 2024. SWE-agent: Agent-Computer Interfaces Enable Automated Software Engineering. In Advances in Neural Information Processing Systems 37 (NeurIPS 2024). 50528–50652. doi:10.52202/079017-1601 [73] Shunyu Yao, Jeffrey Zhao, Dian Yu, Nan Du, Izhak Shafran, Karthik Narasimhan, and Yuan Cao. 2023. ReAct: Synergizing Reasoning and Acting in Language Models. In The Eleventh International Conference on Learning Representations (ICLR). https://openreview.net/forum?id=WE_vluYUL-X [74] Zhiqiang Yuan, Wenjun Mao, Zhuo Chen, Xiyue Shang, Chong Wang, Yiling Lou, and Xin Peng. 2026. Project-Level C-to-Rust Translation via Pointer Knowledge Graphs. Proceedings of the ACM on Software Engineering 3, FSE (2026), 3675–3698. doi:10.1145/3808169 [75] Hanliang Zhang, Cristina David, Yijun Yu, and Meng Wang. 2023. Ownership Guided C to Rust Translation. In Computer Aided Verification (CAV 2023) (Lecture Notes in Computer Science, Vol. 13966). Springer Nature Switzerland, 459–482. doi:10.1007/978-3-031-37709-9_22 [76] Yuntong Zhang, Haifeng Ruan, Zhiyu Fan, and Abhik Roychoudhury. 2024. AutoCodeRover: Autonomous Program Improvement. In Proceedings of the 33rd ACM SIGSOFT International Symposium on Software Testing and Analysis (ISSTA ’24). ACM, 1592–1604. doi:10.1145/3650212.3680384 [77] Tianyang Zhou, Ziyi Zhang, Haowen Lin, Somesh Jha, Mihai Christodorescu, Kirill Levchenko, and Varun Chandrasekaran. 2026. SACTOR: LLM-Driven Correct and Idiomatic C to Rust Translation with Static Analysis and FFI-Based Verification. In Proceedings of the 64th Annual Meeting of the Association for Computational Linguistics (Volume 1: Long Papers), Maria Liakata, Viviane P. Moreira, Jiajun Zhang, and David Jurgens (Eds.). Association for Computational Linguistics, San Diego, California, United States, 646–673. doi:10.18653/v1/2026.acl-long.28 [78] Lydia Zoghbi, David Thien, Ranjit Jhala, Deian Stefan, and Caleb Stanford. 2026. Auditing Rust Crates Effectively. In Programming Languages and Systems (ESOP 2026) (Lecture Notes in Computer Science). Springer, 424–444. doi:10.1007/ 978-3-032-22723-2_15
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
31
Table 6. Examples of C types and functions and the bindings that Crustify emits for them. C item
Rust binding
ffibox primitive
AVBPrint git_object av_file_map
BPrintBox, BPrintOnBuffer<’b> ObjectArc<’r> FileMap
CBox with two policies CArc, reference-counted by C CVec, length passed to its policy
git_checkout_options
CheckoutOptions<’o>
git_commit
CommitRef<’a, ’r>
layout with a hand-written lifetime handle with two lifetimes
git_strarray (result) git_strarray (argument)
StrArrayVal StrArrayView<’a>
av_dict_set
Dict::set, Dict::set_owned
av_malloc, av_free git_odb_backend_data_alloc
HeapBox<T>, HeapVec<T> OdbDataBox<T>, OdbDataVec<T>
av_strdup, av_free av_refstruct_ref
HeapString RefStructBox<T>, RefStructArc<T>
FF_PAD_STRUCTURE AVChannelLayout
PaddedStruct LayoutDetails<’a>
sealed trait over layouts tagged enum with a CSlice variant
av_aes_size
aes::context_size()
&’static shared reference
A
CVal, disposed on drop borrowed view over Rust strings forked: borrowed &CStr or owned HeapString CBox, CVec sharing one policy CBox, CVec with separate policies CStrBox CBox, CArc; CZeroable or MaybeUninit<T>
Binding Examples
This appendix illustrates the binding steps of §3.4.3 with bindings from our campaigns, and maps each to the ffibox primitives of §3.1 that it uses (Table 6). Policies. A policy names the C routine that releases an object and, where they exist, those that clone and allocate it, and the owner it parameterizes calls them on drop, on clone, and on construction (C2, §3.1). Since the routines are bound to the policy rather than to the type, a type can have several. ffmpeg’s AVBPrint is owned either as a BPrintBox, whose policy finalizes the buffer’s own string with av_bprint_finalize and then frees the structure, or as a BPrintOnBuffer<’b>, which prints into a caller’s buffer and whose policy frees only the structure, since finalizing it would free the caller’s buffer, which the owner keeps borrowed for ’b. Both are CBoxes. A referencecounted object is held by a CArc, whose policy also clones by reference count (CRefClone, C6): libgit2’s ObjectArc<’r> gives back its reference with git_object_free on drop and takes another with git_object_dup on clone. It never hands out the exclusive handle (C4), since the repository’s object cache holds a reference that no owner sees, so the count never proves an owner the only one. An array is held by a CVec, which passes its length to its policy (C5): ffmpeg’s FileMap, the contents of a file that av_file_map maps, is a CVec<u8, FileUnmap>, whose policy calls av_file_unmap, which takes the mapping’s size. None of the policies in our campaigns carries run-time state. Lifetimes. Handles carry the lifetime of the memory they reference (C3, §3.1), and a layout type whose fields borrow memory carries that memory’s lifetime too. libgit2’s git_checkout_options becomes CheckoutOptions<’o>, whose labels, target directory, and baseline tree or index are
32
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen
borrowed from the caller for ’o; its label setters, e.g., take an Option<&’o CStr>. A CommitRef<’a, ’r> borrows a commit for ’a, while ’r is the borrow of the repository that the commit was looked up in. Its message method returns a &’a CStr, bound to the commit (§3.4.3), whereas lifetime elision would bind the message to the borrow of the handle, so that it could not outlive the handle it was read through. ffibox’s define_ctype! macro emits only layout types and handles without further lifetime parameters, so these are written by hand, as are all 34 hand-written layout types of the libgit2 bindings (§4.2). Forked pointers. A pointer whose semantics depend on how a function uses it is forked into one Rust form per semantics (§3.4.3). The strings of libgit2’s git_strarray are owned by the array when git_tag_list or a similar function fills it with a result, which the caller must release with git_strarray_dispose, but only borrowed when the caller passes it as an argument, e.g., as a pathspec. A result is therefore held as a StrArrayVal, i.e., a CVal that disposes of the array on drop (C2), whereas an argument is built as a StrArrayView<’a>, which borrows Rust strings for ’a and hands out only the shared handle, so that nothing can replace or free them. ffmpeg’s av_dict_set copies the key and value strings it receives, unless the caller sets AV_DICT_DONT_STRDUP_KEY or AV_DICT_DONT_STRDUP_VAL, in which case the dictionary takes ownership of them. It is forked into Dict::set, which borrows its strings as &CStr, and Dict::set_owned, which takes them as HeapStrings, i.e., strings in ffmpeg’s allocator (see below), and sets the flags itself, so that the flags no longer appear in the safe API. Foundational modules. The foundational modules (§3.4.2) bind the routines that allocate, release, and clone type-erased memory and strings as policies, which the other modules’ bindings reuse (§3.4.3). In ffmpeg, these are mem.c, refstruct.c, and avstring.c. The Heap policy binds av_malloc, av_mallocz, av_free, and av_memdup, and backs both HeapBox<T>, a CBox, and HeapVec<T>, a CVec, so that single objects and arrays have distinct owners but share one policy. libgit2’s public API exports a single raw allocator, git_odb_backend_data_alloc and git_odb_backend_data_free, whose bindings instead give single objects and arrays separate policies, behind OdbDataBox<T> and OdbDataVec<T>. Strings, which C allocates by their content’s length rather than by a type’s size, get policies of their own: ffmpeg’s HeapString is a CStrBox that av_free releases and av_strdup clones, with safe constructors over av_strdup and av_strndup, and the ownership store records functions such as av_asprintf as string constructors (§3.3); libgit2’s counterpart is OdbDataStr. ffmpeg’s refstruct API allocates a reference-counted object of a size and type that the caller chooses, so its policy, which binds av_refstruct_unref, av_refstruct_ref, and av_refstruct_exclusive, is generic over the object’s type, and backs both a sole owner, RefStructBox<T>, and a shared one, RefStructArc<T>, which hands out the exclusive handle only when av_refstruct_exclusive proves its reference the only one (C4). Its constructors return an initialized object only for types that implement CZeroable (av_refstruct_allocz), and MaybeUninit<T> storage otherwise (av_refstruct_alloc_ext_c). Generic types and tagged unions. Families of related types and type-generating macros are bound as generic abstractions (§3.4.3). ffmpeg’s FF_PAD_STRUCTURE pads a structure to a fixed size, so that later versions can add fields without changing its size. Crustify binds it as a PaddedStruct trait, sealed so that only the macro’s instances, such as AVBPrint, implement it, rather than as a generic layout, which would place the padding after the fields’ trailing alignment instead of where C puts it; C uses those bytes, e.g., AVBPrint’s internal buffer extends into the padding. A union is bound with the struct that holds its discriminator, as a Rust enum tagged by it (§3.4.3): AVChannelLayout’s order field selects between a channel mask and a custom channel map in its
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
Opus 5.5
Fable 5.1
GPT-6.1 Sol
GPT-6 Astra
Cost ($)
Type batches
wrapping
33
review
Symbol batches
100
100
30
30
10
10
3
3
1
1 none
1
3
In-scope fields
10
30
1
10
100
1,000
C lines of code
Fig. 2. Cost of each wrapping (filled) and review (hollow) batch of both campaigns against what it wrapped or reviewed: the in-scope fields of its types, with batches of types without in-scope fields, i.e., opaque types and enums, in a separate column, and the C lines of code of its symbols. Both campaigns are pooled; color and marker give the model. The foundational batches, which bind allocation and string routines as lifecycle policies rather than API types and symbols, and one libgit2 review batch, the only one that ran on Opus 4.8, are omitted. All axes are logarithmic.
union u, which Crustify exposes as a LayoutDetails<’a> enum with one variant per order, e.g., Native(u64) and Custom(CSlice<’a, ChannelCustom>). Globals. The public APIs of both targets export only immutable globals, which Crustify exposes as ’static shared references (§3.4.3), e.g., ffmpeg’s av_aes_size, the size of an AES context, as aes::context_size(), which returns a &’static i32. B
Cost Analysis
This appendix extends the cost analysis of §4.4. Figure 2 relates the cost of each batch to what it wrapped or reviewed. Wrapping. The cost of a type batch depends little on the number of fields it wraps (Spearman’s 𝜌 = 0.19, and 𝜌 = 0.41 over the batches that wrap fields), and a batch of types without in-scope fields costs about as much as one with fields (median $5.19 versus $6.58). Wrapping a type thus involves more than its field accessors, likely its lifecycle routines, the uses of the type in the C code that the agent inspects to establish ownership, and its pointer fields, whose representation the agent must decide. A type cost $4.63 on average, between $0.77 and $24.34. The cost of a symbol batch, in contrast, grows with the C code it wraps (𝜌 = 0.67): a symbol cost $0.89 on average, or about $52 per thousand lines of C. The model matters as much as the size: the batches that GPT-6.1 Sol wrapped cost $1.61 per type and $11 per thousand lines, against $4.95 and $58 for Opus 5.5. Counting the reviews, which Opus 5.5 performed for the two sub-campaigns that GPT-6.1 Sol wrapped, libgit2’s config and ffmpeg’s crypto, an item that GPT-6.1 Sol wrapped still cost less, $1.96 in libgit2 and $0.69 in ffmpeg, against $2.09 and $3.13 for one that Opus 5.5 wrapped. Since the two sub-campaigns differ in their code as well as in their model, these figures do not isolate the model’s effect on cost. Review. Review accounts for 43% and 52% of the agents’ cost, and 63% of it went to Fable 5.1, which reviewed the early sub-campaigns. Fable cost $2.09 per reviewed type or symbol, against $0.87 for Opus 5.5, without finding more: it filed 1.6 UB or unsafe reports per $100 of review, against 5.8 for Opus 5.5. Since Fable reviewed earlier sub-campaigns, before most of the corrections of
34
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen
Opus 5.5
Fable 5.1
GPT-6.1 Sol
GPT-6 Astra
30
30 10
10
3 1
1 in de x ul es od
m
vo id
co re re po sit or y ob jec t co nfi g
/st r
in g ut il oid
in de x od ul es m
rin /st
co re re po sit or y ob jec t co nfi g
3
vo id
100 30
30
10
10
3
3 hw co nt ex t m od ul es
op tio ns bu cry ffe pto r-f ra m e
fo rm ats
rin g co re vo id
/st
ul es
ex t
od m
nt hw co
ns bu cr y ffe pto r-f ra m e
tio op
fo rm
/st vo id
ats
1 g co re
1
rin
ffmpeg: cost ($)
review
Symbol batches
100
g ut il oid
libgit2: cost ($)
Type batches
wrapping
Fig. 3. Cost of each wrapping (filled) and review (hollow) batch in campaign order: the x-axis runs over the campaign’s waves, labeled where each sub-campaign starts, the foundational void and string first, and where the per-module waves that followed them start (modules); the batches of one wave, which ran concurrently, are spread around its position, and those of its review wave half a wave later. Color and marker give the model; the libgit2 review batch that ran on Opus 4.8 is omitted. The y-axes are logarithmic.
the setup, this comparison does not isolate the model. Reviewing whole waves instead of single batches reduced the cost per reviewed item in libgit2 from $7.19 to $1.36, and raised the UB and unsafe reports per $100 of review from 2.0 to 3.0, although only 4 reviews covered single batches; in ffmpeg, whose early reviews already covered several batches, the cost per item did not change. Over time. Figure 3 shows the cost of each batch over the course of both campaigns. The cost of a wrapping batch follows its sub-campaign and model rather than its position in the campaign. In libgit2, the batches of the first sub-campaigns (util, oid, and core) cost a median of $4.6 to $6.3, against $11 to $13 for the larger repository, object, and index, and the two sub-campaigns that GPT-6.1 Sol wrapped, libgit2’s config and ffmpeg’s crypto, are the cheapest of their campaigns, with medians of $1.9 and $2.7 (§4.1). The highest median costs of review batches, $31 in libgit2’s repository and $37 in ffmpeg’s formats, both come from sub-campaigns that Fable 5.1 reviewed. C
Comparison of ffimap with Unsafe-Code Scanners
We compare ffimap (§3.2) with three tools that measure unsafe code in Rust crates: cargo-geiger [9] (0.14.0), which parses every source file of a package and counts its unsafe items and expressions; cargo-scan [78], which parses the source with name resolution from rust-analyzer and reports effects, such as FFI calls and raw-pointer dereferences, per call site, optionally after expanding macros (-e, a flag of its implementation [10]); and Clippy [13] (0.1.99), restricted to its lints on unsafe code and pointer casts. All four scan the two crates over libgit2 that Table 2 compares: our libgit2 bindings at the campaign’s tip (17,571 lines of code) and git2-rs 0.21.0 (15,434 lines). We measure only the library crate, excluding -sys crates and tests. Table 7 summarizes the counts.
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
35
Table 7. Counts of ffimap and other scanners on our libgit2 bindings and git2-rs. scan denotes cargo-scan, and scan -e cargo-scan with macro expansion. Metric
Tool
unsafe fn unsafe impl
ffimap / geiger ffimap / geiger / scan
unsafe expressions unsafe blocks Lines in unsafe blocks Blocks with several unsafe operations
geiger ffimap ffimap Clippy
FFI calls Raw-pointer dereferences
ffimap / scan / scan -e ffimap / scan
Crustify
git2-rs
166 / 156 324 / 266 / 266
124 / 123 8/8/8
6,448 2,097 2,911 0
3,336 1,004 3,497 400
592 / 523 / 540 443 / 55
674 / 228 / 646 291 / 215
Unsafe items. On git2-rs, ffimap and cargo-geiger agree to within one item. On our bindings, cargo-geiger’s 266 unsafe implementations are exactly those that the source spells out: it misses the 58 (18%) that ffibox’s macros generate, e.g., define_ctype!, since a scanner that parses source text does not see items that macros generate. Its 156 unsafe functions likewise leave out the 18 that ffibox’s policy macros generate, but include items that ffimap does not count as function definitions, such as unsafe trait-method declarations. cargo-scan reports the same 266 unsafe implementations as cargo-geiger, with or without -e. Unsafe volume. Our bindings follow a convention of one narrow unsafe block per operation, which their lints enforce. They therefore contain 2.1 times as many unsafe blocks as git2-rs, so that cargo-geiger’s expression count rates them as 93% more unsafe, but their blocks span 17% fewer lines, and Clippy reports no block with several unsafe operations, against 400 in git2-rs. FFI calls. git2-rs routes most of its FFI calls through a macro, so cargo-scan finds 228 of them without expansion and 646 with it, within 4% of ffimap’s 674. On our bindings, whose -sys crate keeps its generated declarations in its source tree, cargo-scan finds 523 and 540 of ffimap’s 592 (88% and 91%), but only 55 of its 443 raw-pointer dereferences. The dereferences likely sit mostly in the raw borrows through which the bindings project fields, e.g., &raw const (*p).field: cargo-scan’s parser rejects this Rust 2024 syntax and skips each such expression with a warning. API surface. Only ffimap separates the public interface from the implementation; the other tools count per package, file, or function. Table 2 compares the two crates’ public interfaces as ffimap measures them. Run time. Table 8 gives the wall time of each tool, cold with an empty target directory and warm as the median of three subsequent runs, on the machine of §4.1, which another workload shared, with a load average between 19 and 41 of its 144 hardware threads. ffimap forces a recompilation on each run, since a cached build emits no measurements, so its warm runs re-measure the crate. On git2-rs, it does so 2.4 times faster than cargo-geiger, which re-parses every source file on each run, but slightly slower on our bindings (12.9 against 11.4 s), at 0.36 and 0.16 of cargo-geiger’s memory. Clippy’s warm runs replay cached diagnostics and therefore analyze little. cargo-scan’s cold runs are the slowest, presumably because rust-analyzer loads the workspace and runs its build scripts, which for git2-rs compile the vendored libgit2 sources. Limitations. cargo-scan requires a literal package version, and otherwise reports no effects while exiting successfully; we therefore ran all tools on a copy of our bindings whose manifest states its
36
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen
Table 8. Wall time of each scanner, cold and warm, and peak memory of its warm runs.
Tool ffimap cargo-geiger cargo-scan cargo-scan -e Clippy
Cold / warm (s) Crustify git2-rs 37.2 / 12.9 12.2 / 11.4 38.2 / 26.8 46.2 / 34.6 9.8 / 2.6
18.0 / 5.1 12.4 / 12.2 69.0 / 25.2 99.6 / 55.9 9.5 / 0.45
Peak memory (MB) Crustify git2-rs 358 981 1,018 1,017 250
245 1,530 1,181 1,175 25
Table 9. Dependency graphs of both campaigns’ closures in implementation (impl) and public-signature (sig) mode.
Nodes Edges Non-trivial SCCs Largest SCC (nodes) Nodes in cycles Cut edges Layers (longest chain after cuts) Depth with collapsed SCCs
libgit2 impl
libgit2 sig
ffmpeg impl
ffmpeg sig
7,545 49,528 22 135 3.35% 126 53 66
7,526 7,436 7 8 0.39% 22 7 8
5,777 21,259 19 21 1.47% 43 33 32
5,651 3,400 4 5 0.19% 6 10 10
version, on which ffimap reports the same counts as on the original. We did not run Siderophile or Rudra, which require older pinned toolchains and do not count the unsafe surface. Each crate was measured at a single commit. D
Wavefront Evaluation Details
Setup. We measured Wavefront at commit 6150082 on the relations extracted from both campaigns’ C revisions (libgit2 0551dfd, ffmpeg 1019f8f), with the campaign-wide configurations from which their current plans were made, whose closure holds the files of the campaigns’ modules. We report two graph modes: the implementation graph, which follows function bodies and corresponds to what translation pipelines schedule, and the public-signature graph, from which both campaigns are scheduled (§3.3, D4). Table 9 gives their properties. Cost. Wavefront’s setup runs once per target. With CodeQL 2.26.3, each campaign’s build recipe, and a machine with 144 hardware threads shared with other workloads (load average between 16 and 47), configuring each target took 7 s for libgit2 and 18 s for ffmpeg, building the CodeQL database from a traced build 120 s and 328 s, and extracting its relations with Wavefront’s 25 queries 847 s and 1,034 s, i.e., 34 s and 41 s per query, since each query runs as a separate CodeQL process with a fixed startup cost. Scheduling each campaign as a whole from the extracted relations, with a cold cache and thus including the composition of its public-signature graph, then takes 33 s and 54 s, for about 17 and 24 minutes in all. During the campaigns, agents’ queries took a median of 5.0 s on libgit2 and 37.9 s on ffmpeg, since Wavefront recomposes its records from the extracted relations on every call, whereas cached graph queries took a median of 0.28 s and 1.5 s. Determinism. Regenerating each campaign’s schedule under three hash seeds, with a cold and a warm cache, gives byte-identical schedules, and regenerating it from extracted relations whose
Crustify: Repository-Level Generation of Safe Rust Bindings with LLM Agents and Coverage-Guided Testing
37
rows are shuffled places every item in the same wave and batch, with the same record. libgit2’s schedule is byte-identical to the one that its planner generated; ffmpeg’s differs by one unit, the system’s struct tm, which the planner’s earlier version of Wavefront scheduled before a fix kept forward-declared external tags out of the units. An earlier evaluation exposed an implementation bug that these runs no longer show: Wavefront resolved struct tags that several C files define privately by the order of its extracted relations, which added wrong edges to the graphs from which the campaigns were first scheduled, 214 in ffmpeg and one in libgit2. Reproducibility. Rebuilding both CodeQL databases with each campaign’s build recipe and extracting the relations anew, as timed above, reproduces both campaigns’ schedules: every item is placed in the same wave and batch. E
Comparison with Ownership Inference
&inator. &inator [11] is conceptually closest to Crustify’s ownership analysis: it infers an ownership and borrowing discipline for the interface of a C program, i.e., its struct fields and function signatures, and expresses it as Rust types, by encoding semantic equivalence and Rust’s borrowchecking rules as constraints that Z3 solves over the whole program. We did not run it on our targets, for three reasons. First, its stated limitations exclude features on which both libraries rely: it provides partial or no support for function pointers, polymorphic pointers, unions, and variadic functions, and does not support multi-threaded programs or libraries; libgit2, e.g., allocates and releases memory through the function pointers of its pluggable allocator. Second, it requires the whole program and, for a library, a client test suite that exercises it, and its constraint solving takes several hours for programs of a few thousand lines, whereas the code bases of libgit2 and ffmpeg comprise about 285 and 1,420 thousand lines. Third, its output does not correspond to ours: it selects a single type per pointer, e.g., Box<T> or Rc<RefCell<T», for a rewrite into Rust-owned memory, whereas Crustify’s bindings keep objects in C memory, release them through the library’s own routines, and fork pointers whose ownership depends on the path. Crown and PtrTrans. We compared the ownership records that the agents submitted to Wavefront during the libgit2 campaign, covering 1,124 pointer positions in fields, parameters, and returns of the public API, i.e., the 1,262 positions of RQ5 restricted to functions that libgit2 exports and to public typedefs, with the static ownership inference of Crown [75] and of PtrTrans [74], whose SVF-based analysis computes the ownership labels that its LLM receives. Neither produces usable ownership for libgit2. Crown analyzes C2Rust’s translation of libgit2 in 11 minutes, but only after seven adaptations of the input that work around C2Rust and Crown defects and four patches that keep Crown from aborting on unions and on per-function assertion failures, none of which adds information to the analysis. Its constraints are unsatisfiable for 31% of the 5,638 function bodies, it labels only 38% of the public parameters, 31% of the returns, and 60% of the fields, and it does not model function pointers. Where it does label a position, it agrees with the agents on 92% of the parameters, but only because most API pointers are borrowed and Crown labels nearly every pointer as borrowed: it never labels a public parameter or return as owned, labels all 28 labeled arguments of destructors such as git_repository_free as borrowed, and agrees on the ownership of the objects that constructors write into out-parameters for 3 of 51 positions. Over the whole program, it labels 48 of about 8,100 pointers as owned. PtrTrans’s analysis did not finish: after three and a half hours, it had processed 448 of 5,741 functions, all leaves of the call graph, and none of the 609 structs, since it scans the whole program’s control-flow graph for every pointer parameter. Both tools share a blind spot: they recognize ownership only at calls to the C library’s allocation and release functions, e.g., malloc, realloc, and free, which they match by name, whereas libgit2 allocates and releases all memory through the function-pointer table of
38
Marius Momeu, Marius Arvinte, Cory Cornelius, and Koushik Sen
its pluggable allocator, so that neither sees an allocation or a release in the public API. Crustify’s agents instead read this convention from the code, as a developer writing bindings would. Since agreement with the agents’ records does not establish correctness, these figures compare the tools rather than measure the agents’ accuracy, although the destructor and constructor positions are unambiguous in the C source; we ran the comparison on libgit2, whose allocator neither tool sees, and leave ffmpeg, whose allocator wraps malloc, to future work.
Related documents
- Leaf: An Instrumentation-based Dynamic Analysis Framework for Rust#373453
- HIDE: Hierarchical Isolation via Documentation and Evidence for Rustc Bugs#1473665
- HIDE: Hierarchical Isolation via Documentation and Evidence for Rustc Bugs#1468947
- XRust: Detecting Logical Races in Rust Linux Drivers#1402713
- Annotating and Auditing the Safety Properties of Unsafe Rust#173280
Retrieved via Conceptio: every document is proof-bundled with source, license, and retrieval metadata.
Read the Docs → · View CLI source on GitHub · Conceptio Open Knowledge Archive