From C to Idiomatic Rust: A Ship-of-Theseus Agentic Translation Vasily A. Sartakov
arXiv:2607.28835v1 [cs.SE] 30 Jul 2026
Abstract C underpins operating systems, embedded platforms, and network infrastructure because its abstractions map directly to machine behaviour. Its explicit memory model, predictable data representations, and minimal runtime allow compilers to generate fast, deterministic code. These properties also leave correctness and memory safety entirely to the programmer, making undefined behaviour, pointer misuse, and lifetime errors persistent sources of defects and security vulnerabilities in long-lived C codebases. Rust eliminates most of failure modes through a static ownership and borrowing model that enforces memory safety and aliasing constraints at compile time. However, mature C systems cannot be translated directly: implicit layout assumptions, aliasing patterns, and undefined behaviour must be reconstructed before safe Rust can be produced. This paper presents a migration methodology that first generates a semantics-preserving, non-idiomatic Rust baseline and then incrementally rewrites it into idiomatic Rust using agentic AI, validating each step through compilation and behavioural testing. Applied to iodine (12.5k SLOC), the approach demonstrates that reliable C-to-Rust migration is a structured transformation workflow rather than a single translation step.
CCS Concepts • Do Not Use This Code → Generate the Correct Terms for Your Paper; Generate the Correct Terms for Your Paper; Generate the Correct Terms for Your Paper. ACM Reference Format: Vasily A. Sartakov. 2018. From C to Idiomatic Rust: A Ship-of-Theseus Agentic Translation. In Proceedings of Make sure to enter the correct conference title from your rights confirmation email (Conference acronym ’XX). ACM, New York, NY, USA, 8 pages. https://doi.org/XXXXXXX.XXXXXXX
1
Introduction
C remains one of the most influential and widely deployed programming languages. It provides the implementation foundation for operating systems, embedded systems, networking infrastructure, databases, language runtimes, and many other long-lived software systems. Since its development at Bell Laboratories in the early 1970s, C has remained a fundamental language for systems programming because of its standardisation, portability, and efficient implementation across a wide range of hardware architectures. Permission to make digital or hard copies of all or part of this work for personal or classroom use is granted without fee provided that copies are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. Copyrights for components of this work owned by others than the author(s) must be honored. Abstracting with credit is permitted. To copy otherwise, or republish, to post on servers or to redistribute to lists, requires prior specific permission and/or a fee. Request permissions from [email protected]. Conference acronym ’XX, Woodstock, NY © 2018 Copyright held by the owner/author(s). Publication rights licensed to ACM. ACM ISBN 978-1-4503-XXXX-X/2018/06 https://doi.org/XXXXXXX.XXXXXXX
The enduring popularity of C is largely due to its language abstractions, which closely reflect the underlying machine architecture. The language employs an explicit memory model, provides straightforward procedural control-flow constructs, and defines data representations that are largely predictable within the constraints of the target platform Application Binary Interface (ABI). Consequently, source-level constructs can be related directly to machine-level operations, enabling efficient implementation and optimisation when execution time, memory consumption, or hardware interaction are important. Together with a compact language definition, a stable standardisation process, and mature compilers such as GCC and Clang, these characteristics make C particularly suitable for systems software and other performance-critical applications that require predictable execution costs [6, 10]. Despite its strengths, C provides relatively few language-level guarantees for memory safety or program correctness. The language permits unrestricted pointer arithmetic and direct manipulation of memory, placing responsibility for maintaining object lifetimes, array bounds, and pointer validity entirely on the programmer. As a result, many classes of programming errors are not detected either by the compiler or at runtime. Common sources of software defects include out-of-bounds memory accesses, use-after-free errors, double-free errors, nullpointer dereferences, integer overflow, and the use of uninitialised memory. Such errors frequently result in undefined behaviour, meaning that the C standard imposes no requirements on the outcome of program execution. Undefined behaviour enables aggressive compiler optimisation but also complicates debugging, testing, and formal reasoning about program correctness. In securitycritical software, memory-safety violations remain among the most common causes of exploitable vulnerabilities, including arbitrary code execution, privilege escalation, and information disclosure. These limitations have motivated extensive research on static analysis, dynamic analysis, formal verification, and run-time instrumentation techniques for C programs. Widely adopted tools, including AddressSanitizer, UndefinedBehaviorSanitizer, Valgrind, and numerous static analysers, aim to detect memory errors and undefined behaviour during development. More recently, hardwareassisted protection mechanisms and memory-safe programming languages have been proposed to reduce the prevalence of these vulnerabilities while preserving the performance characteristics required for systems programming. Rust has emerged as a modern systems programming language designed to address many of the memory-safety limitations associated with C while retaining comparable performance characteristics. Unlike C, Rust introduces a strict ownership and borrowing model that enables compile-time verification of memory access patterns, object lifetimes, and data sharing without relying on a garbage collector. This approach eliminates many common classes of vulnerabilities, including use-after-free errors, double-free errors, and data races, while preserving low-level control over memory layout and execution behaviour.
Conference acronym ’XX, June 03–05, 2018, Woodstock, NY
The main advantage of Rust is the ability to provide stronger safety guarantees before program execution. The compiler performs extensive static analysis and rejects programs that violate ownership or borrowing rules, shifting error detection from run time to compilation. This design improves reliability for securitycritical and performance-sensitive software, particularly in areas traditionally dominated by C, such as operating systems, embedded platforms, and network infrastructure. Despite advantages, Rust also introduces additional complexity. The ownership and borrowing model requires programmers to adopt new concepts and can increase initial development effort compared with conventional C programming. However, the safety guarantees provided by Rust create a strong motivation for migrating parts of existing C infrastructure. Replacing memoryunsafe components with memory-safe implementations could significantly reduce vulnerabilities while maintaining the performance characteristics. Such a transition could be particularly valuable in security-critical domains where long-lived C codebases continue to represent a significant source of memory-related defects. Nevertheless, migration from C to Rust is not a simple translation problem. A direct conversion process cannot preserve the behaviour of a mature C codebase while simultaneously guaranteeing Rust safety properties. Existing C programs often depend on implicit assumptions about memory layout, pointer manipulation, object lifetimes, and aliasing patterns that are not explicitly represented in the source code. In addition, undefined behaviour, manual resource management, compiler-specific extensions, and hardware-dependent optimisations are common in low-level C software and may require substantial redesign when expressed in Rust. The difference between the two programming models means that successful migration requires analysis and transformation of program structure rather than a purely syntactic conversion. Recent advances in large language models have demonstrated strong capabilities for code generation and refactoring, making them a promising technology for C-to-Rust migration. However, most existing approaches formulate migration as direct translation from C to idiomatic Rust, placing both semantic recovery and language transformation into a single step. For realistic systems software, this objective remains difficult to achieve reliably. This paper adopts a different perspective. Rather than translating C directly into idiomatic Rust, we treat migration as the transformation of a semantics-preserving but non-idiomatic Rust program into an idiomatic one. We define explicit idiomaticity criteria and present a methodology that incrementally replaces non-idiomatic components using agentic AI whilst preserving a continuously executable hybrid codebase. Each transformation is validated through compilation and behavioural testing before becoming the basis for subsequent migration. The proposed methodology is implemented as an integrated toolchain and evaluated on iodine, a production-quality DNS tunnel comprising approximately 12.5 kSLOC of C code. The study demonstrates that large-scale migration is better approached as a tooling and verification problem than as a single code-generation task, enabling gradual replacement of legacy C constructs with idiomatic Rust while preserving system correctness.
Sartakov et al.
2
Background
C and Rust follow fundamentally different programming paradigms. C relies on manual memory management and unrestricted low-level access, whilst Rust enforces memory safety and concurrency guarantees through its ownership model. This division defines the technical and conceptual challenges involved in translating software from one language to another. Table 1 summarises the key ones. Memory safety. C permits arbitrary pointer arithmetic: a function receiving char *buf, int len can conceptually read or write buf[len+1] or buf[-1]. Rust’s slice type &[u8] combines a pointer with its length, and all accesses are bounds-checked at runtime. Out-of-bound access in C results in Undefined Behaviour (UB) and may not trigger a memory protection fault, whereas in Rust it is guaranteed in safe code. Together, a Rust program requires welldefined bounds, which are not always recoverable from C. Type safety. The void in C performs type erasure in the sense that C retains no type information at runtime: a function taking void *data operates on untyped memory, and the actual type must be coordinated by convention between caller and callee. Rust has no analogue to void. Its generics use parametric polymorphism (fn<T>(x: &T)), preserving type information for the compiler and enabling static checking. Translating uses of void therefore requires reconstructing the intended types, which is a semantic task rather than a syntactic one. Initialisation. C considers uninitialised memory a valid program state. Reading an uninitialised variable is UB, but the compiler does not prevent it. Rust, in turn, requires every variable to be initialised before use, and the compiler rejects code paths that might read uninitialised memory. For translation, it is a data-flow analysis problem: it must identify which C variables are definitely initialised before the first use. Resource management. Rust offers an ownership system that encodes allocation lifetime in the type: every value has exactly one owner, and the compiler inserts deallocation at the end of the owner’s scope. C uses non-controlled manual allocation and deallocation using malloc/free — a source of UB caused by use-after-free or double free. Translating manual memory to ownership requires inferring the lifetime of each allocation and goes beyond tracing the life-cycle of allocated memory: it must reconstruct how resources flow through pointers, aliases, and calling conventions, and map these behaviours onto Rust’s stricter ownership and borrowing rules. Nullability. Every C pointer can be NULL. A function accepting char *s must check to UB caused by the NULL-dereferencing. Rust separates nullable from non-nullable references at the type level: &T is guaranteed non-null; Option<&T> explicitly represents a value that may be absent. Translating C pointers requires determining whether NULL is a valid input, which is another semantic challenge. String model. C represents strings as char* terminated by a NUL byte (\0). String length is computed at runtime via str(n)len, which scans until it finds the terminator or hits the limit. Rust distinguishes UTF-8 strings (&str) from arbitrary byte sequences (&[u8]), with length encoded in the type. A translator must decide, per buffer, whether it holds a C string (NUL-terminated, possibly
From C to Idiomatic Rust: A Ship-of-Theseus Agentic Translation
Conference acronym ’XX, June 03–05, 2018, Woodstock, NY
Table 1: Key differences between C and Rust relevant to automated translation. Dimension
C
Rust
Memory safety Type safety Initialisation Resource mgmt Nullability String model Global state
No bounds checking, UB on overflow void* erases types, implicit casts Uninitialised reads are UB Manual malloc/free, leak/double-free Every pointer may be NULL NUL-terminated char*, strlen scans static mutable, no synchronisation
Bounds-checked slices, overflows panic No void*, no implicit conversions All values initialised before first use Ownership RAII, compiler-enforced drop Option<T>, &T never null &str/&[u8], length in type static mut needs unsafe, Mutex
UTF-8) or a byte array (length-known, arbitrary content). Consequently, translating C strings requires inferring the intended string semantics, not merely the memory layout. Global state. C static variables are mutable by default, and the const qualifier is optional and frequently omitted. Unsynchronised access to a shared state across threads is a data race, and C offers no language-level support to enforce correct synchronisation. Idiomatic Rust models shared state explicitly, using Atomic* types for scalar concurrency primitives and Mutex<T> for compound data. A translator must therefore classify each global according to its effective access pattern: (1) read-only (eligible for const), (2) atomic scalar, or (3) shared mutable state requiring Mutex. Therefore, mapping of C unconstrained global mutability onto Rust’s structured concurrency model requires reconstructing the program’s intended sharing discipline, rather than translating the declarations. These dimensions define the gap that any C-to-Rust translation approach must bridge, and most of them are semantic: they require preserving the intended behaviour of the C code rather than merely transforming its syntax. As it will be shown in the next section, with proper automation and translation methodology, the gap can be bridged with modern Large Language Models.
2.1
The c2rust Transpiler
As will be shown in §3.2, the ultimate approach for the translation from C to Rust is, in fact, a transition from non-idiomatic Rust to the idiomatic one. This is possible because of lossless translation from pure C to non-idiomatic Rust at Clang Abstract Syntax Tree (AST) level, with further iterative modification of software components on the per-function basis. The c2rust [11] transpiler deals with the AST of a compiling C functions. It translates the C sources into Clang’s internal representation, then translates the C AST into the Rust one, and then emits the Rust code for the corresponding AST. The transpiler preserves the control flow graph, the data flow, and the memory model of the original C: (1) functions are declared as extern "C" ABI, (2) global variables become static mut, (3) the pointer arithmetic is possible via .offset()), and (4) C standard library is called via libc FFI. The transpiled code does not introduce any safety. For example, during the transpiling, the C ptr[n] and ptr + n become Rust ptr.offset(n as isize). The point arithmetic is preserved, but bounds are not introduced as this information is not available to the transpiler. As a resilt, functions become unsafe by default. Ultimately, the transpiler just generates C code in Rust syntax, but is a crucial step for the translation.
2.2
Translation Challenges in Action
Let us consider a simplified version of a code ported in this project. It represents typical programming approaches for system software: a buffer manipulation function that decodes a DNS name from wire format. Fig.1 shows the same function in three forms: the original C, the c2rust version, and the final idiomatic Rust after the porting. They illustrate four patterns that resist mechanical translation: 1. Pointer arithmetic as cursor. The C code uses **src (double indirection) to implement a mutable cursor into a buffer. The caller owns the buffer while the cursor tracks a position within it. c2rust preserves this as *mut *mut c_char: an unchecked raw pointer. The idiomatic version replaces this with &mut usize translating the index into a &[u8] slice. The translation requires recognising that *mut *mut c_char is not a generic pointer-to-pointer, but a specific cursor pattern. 2. Implicit length through pointer difference. The C code passes packet and packetlen as separate parameters and computes &packet+ptr via pointer arithmetic. c2rust translates this to packet.offset(ptr as isize), which is not checked against bounds, and relying on the caller to provide a valid offset. The idiomatic version fuses the pointer and length into a single &[u8] slice, eliminating the out-of-bounds bugs at the type level. Such operation requires analyses of the API and propagation the bounds. 3. Manual memory copy via memcpy. The C code uses memcpy to copy label bytes. c2rust preserves this as an FFI call to libc, which preserves all risks associated with unsafe memory modification. The idiomatic version uses copy_from_slice, which panics on overflow rather than producing UB. 4. Null-terminated strings. The C code writes dst[len] = ’\0’ and relies on the caller to provide a large enough buffer. c2rust translates this to *dst.offset(len as isize) = 0 with no checks. The idiomatic version handles the NUL terminator explicitly within the slice, and callers must account for it. This snipped also represents the challenges of UTF-8 strings: DNS names are non-UTF-8, thus &[u8] is used rather than &str. These four examples are endemic to C systems software. Next, we consider our methodology to resolve issues one by one.
3
Methodology
We consider the translation of C to idiomatic Rust as a tooling problem of translation from non-idiomatic Rust to the idiomatic one. Therefore, we introduce criteria to define the idiomatic Rust, and assume that program is properly implemented if it passes all
Conference acronym ’XX, June 03–05, 2018, Woodstock, NY
Sartakov et al.
C (original)
c2rust output
Idiomatic Rust
int readname( char *packet, int packetlen, char **src, char *dst, int length) { int len = **src; if (len > 63) { int ptr = (len & 0x3f) << 8 | *(*src + 1); return readname( packet, packetlen, &packet + ptr, dst, length); } memcpy(dst, *src + 1, len); *src += len + 1; dst[len] = '\0'; return len; }
pub unsafe extern "C" fn readname( packet: *mut c_char, packetlen: c_int, src: *mut *mut c_char, dst: *mut c_char, length: c_int) -> c_int { let len = **src as c_int; if len > 63 { let ptr = ...; return readname( packet, packetlen, packet.offset( ptr as isize), dst, length); } memcpy(dst as *mut _, (*src).offset(1) as *const _, len as size_t); *src = (*src).offset( (len + 1) as isize); *dst.offset( len as isize) = 0; len }
pub fn readname( packet: &[u8], src_pos: &mut usize, dst: &mut [u8], length: usize) -> i32 { let mut src = *src_pos; let c = packet[src]; src += 1; if c & 0xc0 == 0xc0 { let offset = ...; let mut dummy = offset; return readname_loop( packet, &mut dummy, &mut dst[..], length, 10); } let n = c as usize; dst[..n].copy_from_slice( &packet[src..src + n]); src += n; *src_pos = src; n as i32 }
Figure 1: Three versions of the same DNS name decoding function. The c2rust version preserves C’s raw pointer semantics and FFI dependencies; the idiomatic version uses slices, safe indexing, and zero unsafe. functionality tests: produces the same output for the same input as the original program. The tooling component of the system works as trial-and-error loop where the translating codebase always satisfies the functionality correctness criteria, while its components are replaced one-by-one on the per-function basis using LLM-powered reimplementation to satisfy the idiomaticity criteria. Below we consider key components and stages, beginning with definition of idiomatic Rust.
3.1
Criteria for Idiomatic Rust
We define idiomatic Rust along five criteria. These serve as the reference point for evaluating our methodology and its ability to produce Rust code that aligns with established language practices. C1: Zero unsafe in function signatures. A function’s type signature must not expose unsafe to the caller. Localised unsafe {...} blocks remain acceptable for operations that inherently require them (e.g., FFI calls), but the function must present a safe interface. This ensures that safety invariants are enforced at the API boundary rather than delegated to the caller.
while compound global state is wrapped in synchronisation primitives such as Mutex<T>. This removes a major source of undefined behaviour inherited from C. C4: No extern "C" on internal functions. Only functions that participate directly in the FFI boundary (e.g., callbacks invoked from C) may use the extern "C" ABI. All internal Rust functions use the Rust ABI, preserving type checking and calling conventions within the Rust portion of the codebase. C5: No C string or memory functions. C library routines such as strlen, strcpy, memcpy, malloc, and free are replaced with Rust standard library abstractions. String data is represented as &str when UTF-8 validity is guaranteed, or as &[u8] when arbitrary byte sequences are required. This removes unchecked pointer arithmetic and manual memory management from safe code. These five criteria are not exhaustive, but they capture the essential properties that distinguish idiomatic Rust from mechanically translated c2rust output. A codebase satisfying all five is safe by construction in its safe regions, with unsafe confined to the FFI boundary where it is unavoidable.
3.2
Translation Stages
C2: Slice-based buffer access. Buffer parameters must use &[u8] or &mut [u8] instead of raw C pointers such as const c_char or mut c_char. Encoding length in the type eliminates the need for manual bounds management and prevents out-of-bounds access at the call site.
The pipeline comprises three components: (1) an automatic transpiler (c2rust) that produces compilable but non-idiomatic Rust from C source, (2) a language model that performs per-function code transformations, and (3) an infrastructure that provides automated gates, runtime tests, and trace-based diagnostics (Fig. 2).
C3: No static mut. Global mutable state must not rely on static mut, which is incompatible with Rust’s aliasing and concurrency guarantees. Simple global scalars use atomic types (e.g., AtomicUsize),
Verified baseline. The functional correctness is the fundamental oracle against which every subsequent transformation is validated. The building infrastructure responsible for compilation of
From C to Idiomatic Rust: A Ship-of-Theseus Agentic Translation
Verified Baseline
Leaf-tier Analysis
Conference acronym ’XX, June 03–05, 2018, Woodstock, NY
revise
Gate A Compilation
Agentic LLM describe + implement revise + fix
pass
Gate B Runtime
pass
Next Unit or Phase
fail
Divergence
Trace: old vs. new
Figure 2: The gate cycle. Each transformation passes through two gates: compilation (A) and runtime verification (B). When Gate B fails, the diagnostic loop traces execution of old and new implementations, and feeds the divergence back to the language model for revision the project in various configurations: baseline, c2rust version, intermadate transition version, versions with instrumentation. The latter is important to ensure the semantic equivalence at the function level: production the same output and state modification for the same input. Leaf-based ordering. The transition is performed on per-function basis. A static analysis of the c2rust codebase produces a directed call graph in which we select functions with no internal dependencies – leaf nodes. The leaf functions carry minimal risk of unintended side effects. However, the transition to idiomatic Rust impacts the caller functions as arguments passed into leaf function may require bounds. Once all leaves in a given dependency tier are ported, the next tier – functions whose callees now reside entirely in the safe subset – becomes available. This bottom-up ordering guarantees that each transformation rests on a foundation of already-verified safe code. Candidate Implementation. We feed the leaf functions one by one into LLM system. The system should describe what a particular function does, and then implement this functionality in idiomatic rust. The context includes the criteria for idiomaticity, the source code of the C and c2rust versions, and tests if available. The process is iterative and agentic, bound with the Gate A. A and B gates. When candidate function is implemented, the project must compile with zero errors. Beyond type-level errors such as signature mismatches, missing imports, incorrect API usage, the Gate A enforces the idiomatic and safety criteria (§3.1) as hard errors. The system must run under a realistic workload for a sustained period processing real data. If it fails, both the old and new implementations are instrumented with equivalent trace output, executed on identical inputs, and compared. The trace divergence is fed back to the language model, which revises the transformation. This loop repeats until Gate B passes. Two-phase separation. Due to the complexity of the translation processes and limits of language models, any complex modification should be split into a set of basic, atomic steps: ultimately, we consider translation as tooling problem. We separate the translation in two phases. Phase 1 targets function bodies and type signatures, addressing criteria C1, C2, C4, and C5: removing unsafe from function types
(C1), replacing raw pointers with slices (C2), eliminating extern "C" on internal functions (C4), and replacing C string functions with Rust standard library equivalents (C5). This phase is mechanical and high-leverage: it eliminates the majority of unsafe surface area in a project and can proceed without reasoning about shared mutable state or concurrent access. Phase 2 targets global mutable state, addressing the C3 criteria. In this phase, the system converts each static mut declaration to an Atomic* or Mutex<T> wrapper. This phase is challenging because it requires reasoning about lock ordering and potential deadlocks. Within Phase 2, the system follow a risk-ascending priority: atomic scalars first (no lock nesting, C3 satisfied statically), then counters (snapshot-after-increment semantics), then simple Mutex wrappers on single-path data, and finally complex Mutex wrappers on data shared across multiple execution paths. The rationale is that simpler wrappers are mechanically checkable, while complex wrappers require full-system lock-order analysis. Completing Phase 1 before Phase 2 keeps this analysis tractable: with all function signatures already safe (C1, C2, C4, C5 satisfied), the lock-wrapping problem (C3) becomes self-contained.
4
Implementation
We apply the methodology to a concrete case: translating the iodine DNS tunnel 1 from C to idiomatic Rust. Iodine is a real-world systems program of moderate size — approximately 12,500 lines of C across 17 source files — large enough to exhibit structural complexity. It exercises the full range of C idioms that resist mechanical translation: raw pointer arithmetic, static buffers, function pointers stored in structs (via encoder_ops), global mutable state, and extensive use of C string and memory routines such as strlen, strcpy, and memcpy. These are precisely the patterns that c2rust preserves verbatim and that our methodology is designed to eliminate. The system also has a multi-layer client–server architecture: the client encodes and encapsulates traffic into DNS queries, while the server decodes and forwards it, with both sides organised around a select()-based event loop. This section describes the target architecture, the project-specific challenges that emerged during porting and which helped to generalise the methodology. We also provide quantitative metrics used to track progress of the translation. 1 https://github.com/yarrick/iodine
Conference acronym ’XX, June 03–05, 2018, Woodstock, NY
4.1
Iodine Architecture
Iodine is a DNS tunnel that encapsulates IP packets inside DNS queries and responses, allowing IP traffic to traverse networks that only permit DNS. The system has two components, iodined (server) and iodine (client). Both endpoints create and configure a tun device: the client captures outgoing IP packets on its local tun, fragments and encodes them into DNS messages, and sends those messages into the DNS resolution path. The server receives the encoded payloads (typically routed indirectly via recursive resolvers to the authoritative host), decodes and reassembles the IP packets, and injects them into its own tun interface for forwarding. When the client sends an IP packet, iodine processes it through four stages. First, the payload is compressed (zlib via compress2 and uncompress) to reduce size as DNS labels and names impose strict length limits compared to typical MTU sizes. Second, the compressed bytes may be encrypted using a Blowfish cipher keyed by a pre-shared password. Third, the resulting bytes are encoded into a DNS-safe alphabet (one of Base32, Base64, Base64u, or Base128), trading encoding overhead against compatibility with DNS character restrictions. Fourth, the encoded data is embedded in a DNS query name (e.g., encoded-data.topdomain) and sent into the DNS resolution path. The server extracts the payload from incoming queries, reverses the encode/decrypt/decompress pipeline, and writes the reconstructed IP packet to its tunnel device. The reverse direction embeds downstream data in DNS response records (TXT, MX, SRV, CNAME, A, NULL), completing the bidirectional tunnel. State machine. Iodine operates in two principal states. In the handshake state, the client and server negotiate connection parameters: authentication, protocol version, encoding codec, DNS query type, tunnel MTU, and optional EDNS0 extensions. They use multi-message exchanges with timeouts and retries for each stage. Once the handshake completes, the system transitions to the tunnel state, where raw IP traffic flows bidirectionally: upstream from client to server via DNS queries and downstream via DNS responses. Both directions use a sliding-window fragmentation and reassembly protocol, since individual IP packets may be split across multiple DNS messages.
4.2
Porting Challenges
The methodology presented in this work is a human-in-the-loop translation pipeline developed and generalised during translation the Iodine project. In this section, we describe project-specific challenges that emerged during this process. Most of them required the involvement of a human and prevented fully-automated agentic translation. c2rust duplicate struct definitions. c2rust translates each C translation unit independently. Therefore, structs defined in multiple .c files becomes multiple, distinct Rust types. C linker matches struct names, while Rust treats separately generated structs as incompatible types, which prevents replacing extern "C" declarations with use imports and thus blocks function porting. In Iodine, sockaddr_storage appeared in six files, tun_user in two, and packet, connection, and query in three each. These duplicates produced a cascading dependency freeze: an unportable function blocks its callers, which in turn block their callers.
Sartakov et al.
We resolved this by introducing a single canonical module, removing duplicate definitions (common.rs), and adding use imports at call sites. Deduplication must follow dependency order (leaf types first), thus dependent functions can be ported at the signature level. This type-level deduplication is a prerequisite to function porting and is not captured by the leaf-tier ordering. FFI adapter scaffolding. During incremental porting we encountered a recurring intermediate state. Safe Rust implementations of functionality (for example base32::encode) existed, but existing callers still passed C function pointers through a C struct such as encoder_ops, which requires signatures like *const encoder. To bridge this mismatch we introduced thin _ffi wrappers that accept raw pointers, convert them to Rust slices, invoke the safe Rust routine, and write results back into C buffers. Each wrapper was paired with a static mut global holding the function pointer table and often duplicated the original C struct definitions across compilation units. These adapters enable mixed c2rust and Rust execution and let individual functions be ported early. These temporary solution was removed at later stages. Static mut elimination and deadlock hazards. Phase 2 converts static mut declarations to Atomic* or Mutex<T> wrappers. The prescribed order is atomics first, then mutexes, and each global must be wrapped one at a time with full runtime verification after each change. In the iodine client we wrapped 16 static mut globals, applying 11 atomics followed by 3 Mutex<T> wrappers. An attempt to wrap all three mutexes in a single batch produced a live deadlock: the tunnel established but transfers stalled at zero bytes. Reverting to one-at-a-time wrapping with runtime tests exposed two silent deadlock patterns that the compiler does not detect. Both patterns compile and pass static checks but fail only under real traffic. The practical rule is strict: perform Phase 2 one global at a time and run full runtime tests after each wrapping step. Unwrappable static mut from C state. POSIX getopt() communicates via globals (optarg, optind, opterr, optopt), which appear in Rust as extern "C" { static mut optarg: mut c_char; static mut optind: c_int; }. These extern statics cannot be converted to Atomic or Mutex<T> because writes originate inside the C library and bypass any Rust-side wrapper. Demoting static mut to immutable static is ineffective since extern static access remains unsafe. The only sound solution is to remove the C global state by replacing getopt() with a Rust argument parser (for example, iterating std::env::args() and parsing flags). This is a structural refactor for both client and server entry points. We deferred it because the extern statics are confined to main(), used single-threadedly, and present a small, auditable unsafe surface. In the future, this should effect the methodology to treat C library globals as replacement tasks, not Phase 2 wrapping candidates.
4.3
Evolution of Safety
We track the translation progress along two dimensions: declarationlevel C-FFI artefacts and body-level unsafe patterns. The first dimension measures how much of the C ABI surface remains in the c2rust translated codebase. We count unsafe extern "C" fn declarations (XCFN) and #[no_mangle] annotations (NMGL) and combine them into a composite score:
From C to Idiomatic Rust: A Ship-of-Theseus Agentic Translation
XCFN + NMGL SAFE% = 1 − × 100 XCFN0 + NMGL0 The second dimension tracks C-style patterns inside function bodies: raw pointer arithmetic via .add() and .offset() (PtrA), and deep unsafe memory operations such as core::ptr::copy_nonoverlapping and write_bytes (D-UNS). These combine into:
PtrA + D-UNS RUST% = 1 − × 100 PtrA0 + D-UNS0 We exclude the raw unsafe count from scoring. Refactoring moves unsafe from function declarations into narrow blocks around specific FFI calls, improving safety without changing the grep count. Counting unsafe occurrences would penalise proper refactoring. Fig. 3 shows the safety trajectory over the porting process. Each commit here: usually from one to ten functions translated in one session. The initial c2rust-generated code contains 178 XCFN declarations, 73 NMGL annotations, 319 PtrA operations, and 144 deep unsafe memory operations. The first phase (commits 1–22) ports 106 server-side functions, raising SAFE% from 0% to 74% and RUST% from 0% to 47%. The second phase (commits 23–31) ports 62 client functions, completing all 168 function signatures by commit 31. At this point, SAFE% reaches 96% with 184 declaration-level unsafe extern "C" fn sites eliminated (the 5 remaining are signal handler type definitions required for FFI). The third phase (commits 32–45) wraps 37 static mut globals (16 in the client, 21 in the server) with Atomic* or Mutex<T>, reaching final scores of SAFE% = 96% and RUST% = 57%. The RUST% plateau after commit 31 reflects the shift from function porting to state wrapping, which does not further reduce pointer arithmetic but eliminates data-race hazards. The final implementation comprises 10,299 lines of idiomatic Rust distributed across 16 source files, translated from an original 12,500-line C codebase. The resulting implementation contains no unsafe extern "C" fn declarations and no mutable global state (static mut) within the evaluated code. The complete translation required approximately 37 developer-hours over four active working days (4.6 person-days), corresponding to an average throughput of 4.5 translated functions per hour, or approximately 278 Rust source lines per hour. To contextualise the scale of the resulting software, the translated implementation is comparable in size to a 10.3 KSLOC systems software project. According to COCOMO II [1], developing a software system of comparable size from first principles would typically require several tens of person-months of engineering effort under nominal assumptions. Although COCOMO II estimates effort for de novo software development rather than source-to-source migration, it provides a well-established baseline for interpreting the observed translation effort. The measured productivity indicates that LLMassisted semantic translation can produce a production-scale, idiomatic Rust implementation with substantially lower engineering effort than would be expected for an independent redevelopment of equivalent functionality. Models. The proposed methodology does not require long context as ultimately based on iterative re-initialisation of the context for each new function. For each function identified at the current leaf level, we create new context filling the precise and short description
Conference acronym ’XX, June 03–05, 2018, Woodstock, NY
of the task. We performed translation used subscription based plans and the OpenCode-based infrastructure, enhanced by the rtk. A particular model used in transformation does not play important role, however the best result (fever iterations) was achieved with Qwen3.6-27B.
5
Related Work
Rule-based C-to-Rust translation. Compiler-based approaches translate C to Rust using deterministic analyses and source-tosource transformations. C2Rust [11] provides the canonical transpilation pipeline by preserving the original program structure and generating functionally equivalent Rust. Subsequent work extends this foundation through ownership inference [15], alias analysis [4], and specialised transformations for challenging C constructs including output parameters, unions, and C I/O APIs [7–9]. These techniques improve the safety and idiomaticity of the generated code by addressing specific language features while preserving program behaviour. However, they remain compiler-driven transformations that target isolated translation problems rather than the end-to-end migration process. In our methodology, C2Rust serves as the initial lossless translation stage, producing a functionally equivalent but non-idiomatic Rust baseline that is subsequently refined. LLM-based C-to-Rust translation. Recent work employs large language models to generate idiomatic Rust directly from C source code. Existing systems improve translation quality through iterative generation, compilation feedback, repair loops, and validation [5, 12, 14, 16]. Compared with purely compiler-based approaches, these methods often produce more natural and idiomatic Rust by leveraging semantic reasoning. Nevertheless, they primarily formulate migration as a code generation problem, where the model is responsible for synthesising a correct Rust implementation directly from the original C program. In contrast, we formulate migration as a verified refactoring problem: starting from a mechanically translated Rust program, we incrementally transform individual functions into idiomatic Rust while continuously preserving behavioural equivalence through compilation and runtime verification. Project-level migration. Recent research extends C-to-Rust translation beyond individual functions by incorporating repository context, dependency information, build systems, and incremental compilation. RustMap [2], repository-level translation frameworks [3], and EvoC2Rust [13] demonstrate that exploiting projectlevel information substantially improves compilation success and scalability for large codebases. Some of these approaches further suggest that repository evolution, such as commit history, may provide additional semantic context during translation. Our work is complementary to these efforts. Rather than focusing on enriching the translation context, we study the migration workflow itself: dependency-aware function ordering, explicit idiomaticity criteria, incremental replacement of non-idiomatic components, and verification gates that ensure the translated system remains continuously executable throughout the migration process. In our case study, repository history was not required to perform the migration, suggesting that continuous verification and dependency-aware orchestration can be sufficient without historical development context, but conceptually can be combined.
Conference acronym ’XX, June 03–05, 2018, Woodstock, NY
Server porting
Client porting
Static-mut elim.
300
PtrA
250 Count
100 XCFN
80
SAFE% RUST%
200 150
60 40
100 20
50 0
Safety Score (%)
350
Sartakov et al.
1
10
20
30
40
0
Commit
Figure 3: Decreasing the amount unsafe code with project progress
6
Future Work
This work will be extended in three directions: migration orchestration, verification-driven translation, and whole-project reasoning. First, the current workflow still relies on human judgement to select migration units, order transformations, and manage temporary compatibility layers. A more capable system would analyse dependency structure, plan transformation sequences, and maintain consistent intermediate states so that the project remains buildable. Second, compilation and behavioural testing validate each step, but the process is largely reactive. Combining runtime testing with static analysis, property checking, symbolic execution, or automated test generation could provide stronger correctness guarantees and reduce the amount of manual oversight required. Third, although many functions can be migrated independently, architectural changes — such as redesigning shared state or replacing C-specific libraries — require reasoning across the entire codebase. Repository-level information, including dependency graphs and commit history, may help recover developer intent and guide these broader transformations. This information was not needed in our study, but may be important for full-project migration. Overall, future work should focus on systems that can plan, verify, and coordinate large numbers of incremental transformations while keeping the software continuously functional.
7
Conclusion
C remains the foundation of much deployed systems software, but its unchecked pointer arithmetic, manual memory management, and unconstrained global state make long-term maintenance and security difficult. Rust offers stronger safety guarantees, yet migrating existing C codebases is hard because idiomatic Rust requires semantic information that C does not encode. In this paper, we present a translation pipeline that treats C to Rust migration as a sequence of correctness-preserving, per-function replacements. Starting from a lossless c2rust baseline, we use an agentic loop that rewrites functions into idiomatic Rust while enforcing behavioural equivalence through runtime gates.
Acknowledgements ChatGPT and Copilot were utilised to proofread sections of this Work, including text, tables, graphs.
References [1] Barry W Boehm, Chris Abts, A Winsor Brown, Sunita Chulani, Bradford K Clark, Ellis Horowitz, Ray Madachy, Donald J Reifer, and Bert Steece. 2009. Software cost estimation with COCOMO II. Prentice Hall Press. [2] 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 International Conference on Engineering of Complex Computer Systems. Springer, 283–302. [3] Saman Dehghan, Tianran Sun, Tianxiang Wu, Zihan Li, and Reyhaneh Jabbarvand. 2025. Translating Large-Scale C Repositories to Idiomatic Rust. arXiv preprint arXiv:2511.20617 (2025). [4] Mehmet Emre, Peter Boyland, Aesha Parekh, Ryan Schroeder, Kyle Dewey, and Ben Hardekopf. 2023. Aliasing limits on translating C to safe Rust. Proceedings of the ACM on Programming Languages 7, OOPSLA1 (2023), 551–579. [5] Hasan Ferit Eniser, Hanliang Zhang, Cristina David, Meng Wang, Maria Christakis, Brandon Paulsen, Joey Dodds, and Daniel Kroening. 2025. Towards Translating Real-World Code with LLMs: A Study of Translating to Rust. arXiv:2405.11514 [cs.SE] https://arxiv.org/abs/2405.11514 [6] Jens Gustedt. 2019. Modern C (2 ed.). Manning Publications. [7] Jaemin Hong and Sukyoung Ryu. 2024. Don’t write, but return: Replacing output parameters with algebraic data types in c-to-rust translation. Proceedings of the ACM on Programming Languages 8, PLDI (2024), 716–740. [8] Jaemin Hong and Sukyoung Ryu. 2024. To tag, or not to tag: Translating c’s unions to rust’s tagged unions. In Proceedings of the 39th IEEE/ACM International Conference on Automated Software Engineering. 40–52. [9] Jaemin Hong and Sukyoung Ryu. 2025. Forcrat: Automatic I/O API Translation from C to Rust via Origin and Capability Analysis. arXiv preprint arXiv:2506.01427 (2025). [10] Brian W. Kernighan and Dennis M. Ritchie. 1988. The C Programming Language (2 ed.). Prentice Hall. [11] Erik Kristensen, Peter Korley, Anders Andersen, Zvonimir Pavlinovic, Thomas Wies, and Shuvendu K. Lahiri. 2022. C2Rust: A Tool for Migrating C Code to Rust. In Proceedings of the 44th International Conference on Software Engineering: Software Engineering in Practice (ICSE-SEIP). ACM. [12] Momoko Shiraishi, Yinzhi Cao, and Takahiro Shinagawa. 2024. SmartC2Rust: Iterative, Feedback-Driven C-to-Rust Translation via Large Language Models for Safety and Equivalence. arXiv preprint arXiv:2409.10506 (2024). [13] Chaofan Wang, Tingrui Yu, Beijun Shen, Jie Wang, Dong Chen, Wenrui Zhang, Yuling Shi, Chen Xie, and Xiaodong Gu. 2025. Evoc2rust: A skeleton-guided framework for project-level c-to-rust translation. arXiv preprint arXiv:2508.04295 (2025). [14] Zhen Yang, Fang Liu, Zhongxing Yu, Jacky Wai Keung, Jia Li, Shuo Liu, Yifan Hong, Xiaoxue Ma, Zhi Jin, and Ge Li. 2024. Exploring and unleashing the power of large language models in automated code translation. Proceedings of the ACM on Software Engineering 1, FSE (2024), 1585–1608. [15] Hanliang Zhang, Cristina David, Yijun Yu, and Meng Wang. 2023. Ownership guided C to Rust translation. In International Conference on Computer Aided Verification. Springer, 459–482. [16] Han Zhou, Yu Luo, Mengtao Zhang, and Dianxiang Xu. 2025. C2RustTV: An LLM-based Framework for C to Rust Translation and Validation. In 2025 IEEE 49th Annual Computers, Software, and Applications Conference (COMPSAC). 1254–1259. doi:10.1109/COMPSAC65507.2025.00158