Translating QIP Components To C

qip-component-to-c.wasm translates a bounded QIP Content component into one C11 header. The generated header contains the translated functions, a module instance type, a host-supplied linear-memory pointer, explicit memory checks, dirty-page tracking, trap recovery, and a small wrapper for the QIP render lifecycle.

The first implementation deliberately accepts less than general WebAssembly. That keeps the generated runtime small enough to audit and gives unsupported instructions an unambiguous failure mode.

Build the translator and run it like any other QIP Content component:

make -j components/application/wasm/qip-component-to-c.wasm
wasm-validate components/text/markdown/commonmark.0.31.2.wasm
./qip run \
  -i components/text/markdown/commonmark.0.31.2.wasm \
  -o commonmark.h \
  components/application/wasm/qip-component-to-c.wasm

The translator enforces its feature profile, but it is not a complete WebAssembly validator. Validate untrusted input before translation. This is an artifact-generation step: do not expose the translator as a service that accepts arbitrary Wasm and compiles the resulting C.

Generated Header #

Every header hashes the complete input bytes with SHA-256 and uses the first 128 bits as its module identifier. The identifier prefixes public symbols, private helpers, the include guard, and the instance type. Independently translated modules can therefore be compiled into one program without linker collisions in normal use. The complete digest remains in a comment at the top of the header for provenance.

Define the generated implementation in exactly one translation unit:

#define QIP_WASM_IMPLEMENTATION
#include "generated_component.h"

Other translation units include the same header without defining QIP_WASM_IMPLEMENTATION.

Compile the implementation as C11 and link the platform math library:

cc -std=c11 -O2 app.c -lm

The public interface has this shape, with the hash-specific prefix substituted for qip_wasm_ID:

typedef struct qip_wasm_ID_instance qip_wasm_ID_instance;

typedef struct qip_render_workspace {
    uint8_t *memory;
    size_t memory_size;
} qip_render_workspace;

qip_wasm_ID_status qip_wasm_ID_init(
    qip_wasm_ID_instance *,
    qip_render_workspace *,
    uint32_t input_size);

uint32_t qip_wasm_ID_dirty_page_count(
    const qip_wasm_ID_instance *);

qip_wasm_ID_status qip_wasm_ID_render(
    qip_wasm_ID_instance *,
    uint32_t input_size,
    uint32_t *output_offset,
    uint32_t *output_size);

Each header publishes hash-prefixed INPUT_OFFSET, INPUT_CAPACITY, OUTPUT_CAPACITY, and MEMORY_SIZE constants. The caller writes input directly into the workspace before init. render performs no host-buffer copies; it returns the dynamic output offset and length.

A workspace allocation contains the Wasm-visible memory followed by a small private trailer holding a generation and one dirty bit per 64 KiB Wasm page. qip_render_workspace_allocation_size(memory_size) returns the complete allocation size. The public workspace remains a two-field host value:

size_t allocation_size =
    qip_render_workspace_allocation_size(recipe_memory_size);
uint8_t *allocation = calloc(1, allocation_size);
qip_render_workspace workspace = {
    .memory = allocation,
    .memory_size = recipe_memory_size,
};

The allocation must initially be zero. Generated code never allocates or frees it. The trailer sits beyond the translated Wasm bounds, and touching it does not make the intervening lazy-zero linear-memory pages resident.

init is a workspace turnover operation. It preserves exactly the new component's caller-written input, clears previously dirty bytes outside that range, initializes globals, tables, and active data, and advances the workspace generation. A previous component instance then returns STALE_INSTANCE if it is used accidentally.

Recipe steps therefore need no explicit reset and no external intermediate buffer:

memcpy(
    workspace.memory + step_a_INPUT_OFFSET,
    input,
    input_size);
step_a_init(&a, &workspace, input_size);
step_a_render(&a, input_size, &output_offset, &output_size);

memmove(
    workspace.memory + step_b_INPUT_OFFSET,
    workspace.memory + output_offset,
    output_size);
step_b_init(&b, &workspace, output_size);
step_b_render(&b, output_size, &output_offset, &output_size);

step_b_init clears the abandoned source bytes while preserving the relocated input. The caller checks output_size against step_b_INPUT_CAPACITY before the memmove.

Call qip_render_workspace_clear(&workspace) only when no next component will take over and the host wants the workspace returned to zero. It invalidates the current instance but does not free the allocation.

The current public API covers the Content render lifecycle. It does not yet publish wrappers for optional uniform_set_* exports, so components that depend on host-configured uniforms need that API added before native use.

Translation Profile #

The native translation profile accepts:

call_indirect checks the table index, rejects an uninitialized slot, and checks the selected function's structural type before dispatch. Each failure returns a distinct trap status. return_call and return_call_indirect remain unsupported.

This is a native-translation profile, not a revision of QIP's strict profile. The strict profile proves an acyclic direct call graph by rejecting indirect calls. The native profile instead applies a runtime call-depth limit once call_indirect is enabled.

Memory And Traps #

Wasm addresses remain uint32_t. Linear memory is a fixed-size, host-owned buffer; the native profile does not support memory.grow. The generated memory helper widens the dynamic address and static offset to uint64_t, then checks the complete range before producing a native pointer. This catches address addition overflow and accesses that cross the end of memory.

The host chooses how to obtain the initially zeroed buffer. A single calloc is portable C11 and normally receives lazy zero-filled pages on the target x86-64 and AArch64 systems. Applications can instead use mmap, VirtualAlloc, a persistent application arena, or another allocator whose lifetime fits the surrounding program.

Every generated write marks its 64 KiB Wasm page in the workspace trailer. dirty_page_count reports the number of marked pages for the workspace's current instance. The next init clears those pages except for the new input range. qip_render_workspace_clear clears every marked page.

Dirty tracking is enabled by default. Defining QIP_WASM_DIRTY_TRACKING=0 before including the implementation removes the bitmap writes for comparative benchmarks. In that mode, workspace turnover must clear the complete memory range outside the preserved input and can make previously untouched lazy-zero pages resident.

Scalar loads and stores use fixed-size memcpy. The generated code targets little-endian x86-64 and AArch64, so no byte swap is required. memcpy avoids unaligned pointer dereferences and C aliasing violations.

Bulk-memory operations check both ranges before mutation:

The public render wrapper establishes a setjmp boundary. An explicit trap, failed memory check, invalid numeric conversion, invalid indirect call, or call-depth exhaustion returns a typed status rather than terminating the host application. A trap does not roll back component memory or globals; subsequent renders reuse the instance just as repeated Wasm renders do.

Comparison With RLBox #

RLBox and QIP's C translation both use WebAssembly's linear-memory and control-flow model to isolate native code inside a process. They apply that mechanism to different integration problems. RLBox adapts general C library APIs to C++ applications. QIP translates components that already expose one narrow Content contract: bytes enter at a bounded offset, render runs without imports or callbacks, and bounded bytes leave at a validated offset.

The RLBox Wasm backend has three distinct layers:

  1. WebAssembly and wasm2c-generated checks confine the library's memory

accesses and indirect calls.

  1. The

RLBox wasm2c backend creates the sandbox memory and table, converts 32-bit sandbox pointers, marshals ABI values, and mediates allocation and callbacks.

  1. The RLBox C++ API controls how application code calls the library and uses

values returned from it.

The tutorial's CMakeLists.txt assembles that build: it compiles the library to Wasm, runs wasm2c, compiles the generated C with its runtime, and links the RLBox backend. CMake is not itself a runtime isolation mechanism, although its flags and selected WASI imports determine what code and external capabilities enter the sandbox. The RLBox Wasm sandbox tutorial shows the complete pipeline.

Tainted Host Values

RLBox wraps values originating in a sandbox as tainted<T>. For a fundamental value such as an integer, this is primarily a C++ type-system boundary: ordinary host operations that would use the value unsafely do not compile. Permitted computations propagate the taint. The application must eventually call an untainting API such as copy_and_verify, supplying a verifier, before it receives an ordinary host value.

The wrapper cannot decide whether the verifier expresses the application's correct security policy. A developer can write an incomplete verifier or use an explicitly named escape hatch. Its safety contribution is to prevent unacknowledged use, keep untrusted state visible through supported computations, and make each trust decision a reviewable code location. See RLBox's function-call tutorial and untainting APIs.

Pointers involve more than the wrapper alone. The backend converts sandbox offsets to native addresses and checks that a requested range belongs to the sandbox. Copy-and-verify APIs can clone data into host-owned memory before validation, preventing the sandbox from changing that copy concurrently. APIs that deliberately expose a raw pointer perform boundary checks but leave the referenced bytes mutable by the sandbox.

QIP's generated render wrapper instead checks that the returned offset and length stay within linear memory and the component's declared output capacity. It then returns ordinary uint32_t values. The output bytes remain untrusted application data, but the C API does not use a taint type to force the host to acknowledge that fact. Passing those bytes to another translated component keeps their processing inside a bounded memory model; consuming them in the host still requires validation appropriate to that use.

Scope And Trade-Offs

ConcernQIP generated CRLBox with wasm2c
Library interfaceFixed Content byte transformGeneral C library functions and types
Linear-memory isolationExplicit checks generated into Cwasm2c checks or guarded memory
Returned valuesBounds-checked offsets and lengths become ordinary C valuesSandbox values remain tainted<T> until explicitly unwrapped
PointersWasm offsets are mostly hidden behind the workspace and Content ABIGeneric pointer conversion, ownership, and range checks
Host callbacksRejected by the translation profileExplicitly registered and mediated
External accessImports are rejectedSelected imports, commonly including WASI facilities, may be provided
AllocationOne fixed host-owned workspace, reusable between componentsSandbox heap allocation through a general library boundary
CompositionSequential components can reuse one allocationLibraries normally retain separate sandbox instances
Host integrationC11 header and application-defined validationC++ framework with compile-time taint propagation

QIP's restrictions remove much of the boundary surface that RLBox must mediate: there are no arbitrary library pointers, structs, callbacks, allocators, or system calls in the translated interface. That smaller surface also gives up APIs that need those features. RLBox supports richer existing libraries and makes host trust decisions explicit, at the cost of a more involved C++ integration and per-library API adaptation.

The shared QIP workspace introduces a separate trade-off. Reuse lowers the combined memory requirement of sequential recipes, but separation between successive components depends on correct dirty tracking and turnover. Separate RLBox sandbox allocations do not normally share that lifecycle. Conversely, they cannot reclaim the largest component's storage for another heterogeneous step without additional host coordination.

Both approaches include their translators, generated checks, runtimes, native compiler, and host boundary code in the trusted computing base. RLBox and its wasm2c integration have substantially more deployment history. QIP's native profile is newer, but rejects imports, threads, memory growth, and most optional WebAssembly features rather than implementing a general library environment.

When Not To Use It #

Use a normal WebAssembly runtime when the input needs WASI, imports, threads, memory growth, runtime compilation, memory64, or proposals outside the translation profile. Generated C is also a poor interchange format when the application already embeds a mature Wasm engine: it increases build size and moves validation from runtime admission to the artifact-generation step.

The generated code is intended for applications that need a fixed collection of reviewed QIP components compiled into the native binary.

See QIP Component-to-C Runtime Benchmarks for measurements against QIP/wazero, Node/V8, WABT wasm2c, and direct native Zig builds.