Introduction
Ferricel compiles CEL (Common Expression Language) expressions into WebAssembly modules.
The produced .wasm files can then be executed in any Wasm runtime.
Components
Ferricel provides the following components:
ferricel: a CLI tool that can be used to either compile or run a.wasmmodule produced by itferricel-core: the pure Rust crate used byferricelCLI. It can be used to embed a compiler or a runtime inside of your Rust program
The next sections cover in depth how to handle compilation of CEL expressions and how to run them.
Spec
The “Wasm Spec” section illustrates the low level details of the WebAssembly modules produced by the ferricel compiler.
Compilation
The ferricel CLI tool can be used to build a CEL program to WebAssembly.
You can find more details here.
CEL Compliance
Ferricel targets full compliance with the CEL specification and the cel-go extension libraries. Conformance is validated against the official CEL conformance test suite.
Specification Coverage
The next sections outline which parts of the different CEL specifications are currently supported by the ferricel compiler.
Core CEL Specification
| Feature | Status |
|---|---|
Integer arithmetic (+, -, *, /, %) with overflow detection | Supported |
| Floating-point arithmetic | Supported |
Unsigned integer (uint) arithmetic | Supported |
Boolean logic (&&, ||, !) with short-circuit evaluation | Supported |
Comparison operators (==, !=, <, <=, >, >=) | Supported |
String operations (+, size, contains, startsWith, endsWith, matches) | Supported |
| Bytes operations | Supported |
List literals, indexing, membership (in) | Supported |
| Map literals, field access, indexing | Supported |
Conditional expressions (? :) | Supported |
| Null handling and null propagation | Supported |
Type conversions (int(), uint(), double(), string(), bytes(), bool()) | Supported |
| Timestamp and Duration types | Supported |
| Timestamp/Duration arithmetic and field accessors | Supported |
size() function | Supported |
Single-variable comprehensions (all, exists, exists_one, map, filter) | Supported |
Logical error propagation through && / || | Supported |
Optional types (optional.of, optional.none, .orValue, .value) | Supported |
| Protocol Buffer message construction and field access | Supported |
Protobuf wrapper type semantics (google.protobuf.*Value) | Supported |
dyn() type erasure | Supported |
type() introspection | Supported |
cel-go Extension Libraries
| Extension | Functions | Status |
|---|---|---|
| Bindings | cel.bind(var, init, body) | Supported |
| Encoders | base64.encode, base64.decode | Supported |
| Math | math.greatest, math.least | Supported |
math.bitOr, math.bitAnd, math.bitXor, math.bitNot | Supported | |
math.bitShiftLeft, math.bitShiftRight | Supported | |
math.ceil, math.floor, math.round, math.trunc | Supported | |
math.abs, math.sign | Supported | |
math.isInf, math.isNaN, math.isFinite | Supported | |
math.sqrt | Supported | |
| Strings | charAt, indexOf, lastIndexOf | Supported |
lowerAscii, upperAscii, trim | Supported | |
replace, split, substring, join | Supported | |
reverse, strings.quote | Supported | |
format (string interpolation) | Supported | |
| Lists | slice, flatten, distinct, reverse | Supported |
sort, sortBy | Supported | |
first, last | Supported | |
lists.range(n) | Supported | |
| Sets | sets.contains, sets.equivalent, sets.intersects | Supported |
| TwoVarComprehensions | all(i, v, pred), exists(i, v, pred), existsOne(i, v, pred) | Supported |
transformList(i, v, [filter,] expr) | Supported | |
transformMap(i, v, [filter,] expr) | Supported | |
transformMapEntry(i, v, [filter,] mapExpr) | Supported | |
| Regex | regex.replace, regex.extract, regex.extractAll | Supported |
| Protos | proto.getExt, proto.hasExt | Not supported |
Kubernetes CEL Extensions
Ferricel also supports the Kubernetes CEL validation libraries:
| Extension | Status |
|---|---|
IP address functions (ip(), isIP(), family(), etc.) | Supported |
CIDR functions (cidr(), isCIDR(), containsIP(), etc.) | Supported |
URL functions (url(), isURL(), getHost(), etc.) | Supported |
Quantity functions (quantity(), isQuantity(), add(), sub(), etc.) | Supported |
Semver functions (semver(), isSemver(), major(), minor(), patch(), etc.) | Supported |
Format validation (format.named(), format.dns1123Label(), etc.) | Supported |
List extensions (isSorted(), sum(), min(), max()) | Supported |
Regex extensions (find(), findAll()) | Supported |
Conformance Tests
This is an overview of the current status of the conformance tests:
| Test Suite | Successful | Failed | Skipped |
|---|---|---|---|
| basic | 41 | 2 | 0 |
| bindings_ext | 8 | 0 | 0 |
| block_ext | 18 | 8 | 11 |
| comparisons | 406 | 0 | 0 |
| conversions | 109 | 0 | 0 |
| encoders_ext | 4 | 0 | 0 |
| fp_math | 30 | 0 | 0 |
| integer_math | 64 | 0 | 0 |
| lists | 39 | 0 | 0 |
| logic | 30 | 0 | 0 |
| macros2 | 46 | 0 | 0 |
| macros | 44 | 0 | 0 |
| math_ext | 199 | 0 | 0 |
| namespace | 11 | 3 | 0 |
| network_ext | 69 | 0 | 0 |
| optionals | 70 | 0 | 0 |
| parse | 128 | 74 | 17 |
| string_ext | 216 | 0 | 0 |
| string | 51 | 0 | 0 |
| timestamps | 76 | 0 | 0 |
| type_deduction | 17 | 1 | 29 |
| Total | 1676 | 88 | 57 |
Wasm Spec
This chapter provides the low-level details about the WebAssembly module produced by the ferricel compiler.
This information is useful if you plan to load the .wasm modules directly
without using the helpers provided by the ferricel-core crate.
Data Exchange
The host and the Wasm guest exchange data using the JSON format.
The host reads and writes data to the WebAssembly linear memory.
Several functions pass or return memory regions through a single i64 value.
The rest of this document refers to this value as “packed pointer”.
The encoding packs a pointer and a byte length into the two 32-bit halves:
bits 0–31 → pointer (offset into Wasm linear memory)
bits 32–63 → byte length
In pseudo-code:
packed = (len as i64) << 32 | (ptr as i64)
ptr = (packed & 0xFFFF_FFFF) as u32
len = (packed >> 32) as u32
Memory Management
Important
The code produced by the compiler uses a leaking allocator: memory is never released during evaluation.
Given the short lifetime of a CEL program, this is not an issue in practice.
Do not reuse an instantiated WASM module for multiple evaluations—its memory usage will keep growing. Instead, instantiate a new WASM module for each evaluation.
Exported Functions
cel_malloc(len: usize) -> *mut u8
Allocates len bytes in Wasm linear memory and returns a pointer to the
allocated buffer.
| Parameter | Type | Description |
|---|---|---|
len | usize | Number of bytes to allocate. |
Returns a pointer to the newly allocated buffer within Wasm linear memory.
The allocator is a bump-pointer arena: dealloc is a no-op and memory is
released only when the host drops the Wasm instance. Hosts must call this
function to obtain a valid buffer before writing input data (e.g. bindings)
into Wasm memory.
Use this function to load into the instantiate Wasm module the data to be processed.
cel_set_log_level(level: i32)
Sets the minimum log level for messages emitted via env::cel_log.
| Parameter | Type | Description |
|---|---|---|
level | i32 | Log level threshold (see table below). Values outside the valid range are clamped. |
The log levels are:
| Value | Level |
|---|---|
0 | Debug |
1 | Info (default) |
2 | Warn |
3 | Error |
Messages below the configured level are suppressed and env::cel_log will not
be called for them.
evaluate(bindings: i64) -> i64
Evaluates the compiled CEL expression using JSON-encoded variable bindings.
| Parameter | Type | Description |
|---|---|---|
bindings | i64 | Packed pointer to a UTF-8 JSON object mapping variable names to their values. |
The bindings value points to the data previously loaded via cel_malloc.
Returns a packed i64 pointing to a UTF-8 JSON string that contains the
result of the CEL expression.
If the expression produces a runtime error (overflow, divide-by-zero, unbound variable, etc.) the module traps and the host receives an error from the call.
evaluate_proto(bindings: i64) -> i64
Evaluates the compiled CEL expression using Protobuf-encoded variable bindings.
| Parameter | Type | Description |
|---|---|---|
bindings | i64 | Packed pointer to a serialized ferricel.Bindings protobuf message. |
Returns a packed i64 pointing to a UTF-8 JSON string that contains the
result of the CEL expression (same format as evaluate).
Runtime errors cause a trap, identical to evaluate.
Imported Functions
The module imports three functions from the env module. All three must be
provided (or stubbed) by the host at instantiation time.
env::cel_log(ptr: i32, len: i32)
Called by the runtime to emit a structured log event.
| Parameter | Type | Description |
|---|---|---|
ptr | i32 | Offset in Wasm linear memory of a UTF-8 JSON-encoded LogEvent object. |
len | i32 | Byte length of the JSON payload. |
The LogEvent object is a JSON structure like:
{
"level": "error",
"message": "division by zero",
"file": "main.rs",
"line": 42,
"column": 15,
"extra": { ... }
}
The level field is one of "error", "warn", "info", or "debug".
The extra field is optional and contains arbitrary key-value pairs.
A host that does not need log output can satisfy this import with a no-op function.
env::cel_abort(packed: i64)
Called by the runtime when a fatal runtime error occurs (e.g. divide-by-zero, integer overflow, unbound variable).
| Parameter | Type | Description |
|---|---|---|
packed | i64 | Packed pointer to a UTF-8 error message. |
The host implementation is expected to surface the error message and return an
error to the caller (e.g. by trapping or returning Err).
env::cel_call_extension(request: i64) -> i64
Invokes a host-provided extension function by name.
| Parameter | Type | Description |
|---|---|---|
request | i64 | Packed pointer to a UTF-8 JSON-encoded extension call request. |
The request JSON has the structure:
{
"namespace": "math",
"function": "greatest",
"args": [10, 20, 15]
}
Where namespace is optional (may be null), function is the function name, and args is an array of JSON-encoded CEL values.
Returns a packed i64 pointing to a UTF-8 JSON string containing the result. The response is a serialized CelValue:
{
"type": "int",
"value": 20
}
The Wasm module calls this import whenever a compiled CEL expression invokes a
function that was registered as a host extension at compile time. The host is
responsible for dispatching the call to the correct implementation based on the
namespace and function fields in the request. If no extensions are
used, this import can be satisfied with a stub that traps.
Note
This import is only present if the compiled CEL expression uses host extensions.
Compiling a CEL Program to Wasm
The ferricel build command compiles a CEL expression into a self-contained
WebAssembly module.
The compiler removes all unused runtime functions from the final Wasm module.
Beyond that, it performs no additional optimizations. For smaller or faster
binaries, you can post-process the output with wasm-opt.
Basic Usage
Pass a CEL expression directly with --expression:
ferricel build --expression '1 + 1' --output result.wasm
Or provide the expression in a file with --expression-file:
ferricel build --expression-file my_program.cel --output result.wasm
The two flags are mutually exclusive. If --output is omitted, the module is
written to final_cel_program.wasm in the current directory.
Host Extensions
Host extensions allow the compiled CEL expression to call functions implemented
by the host at evaluation time. Without a declaration, the compiler treats any
unknown function call as a no matching overload error baked statically into
the Wasm module. Declaring an extension tells the compiler to emit a real host
call instead, dispatched at runtime via cel_call_extension.
Extensions can be declared inline with --extensions (repeatable):
ferricel build \
--expression 'math.sqrt(x)' \
--extensions math.sqrt:global:1 \
--output result.wasm
Or in a JSON file with --extensions-file:
ferricel build \
--expression 'math.sqrt(x)' \
--extensions-file extensions.json \
--output result.wasm
The two flags are mutually exclusive.
--extensions format
Each --extensions value follows the pattern [namespace.]function:style:arity:
| Segment | Description |
|---|---|
namespace | Optional dot-separated namespace prefix (e.g. math in math.sqrt). |
function | Function name (the last dot-separated segment). |
style | One of global, receiver, or both (see below). |
arity | Total number of arguments the host receives, including the receiver for receiver-style calls. |
Calling styles:
| Style | Invocation form |
|---|---|
global | func(args) or ns.func(args) |
receiver | value.func(extra_args) — receiver is always args[0] |
both | Both of the above |
Examples:
--extensions abs:global:1 # abs(x) — 1 arg
--extensions math.sqrt:global:1 # math.sqrt(x) — namespace "math", 1 arg
--extensions math.pow:global:2 # math.pow(base, exp) — 2 args
--extensions reverse:receiver:1 # x.reverse() — receiver counts as the 1 arg
--extensions greet:both:2 # greet(name, lang) and name.greet(lang)
--extensions-file format
The file must contain a JSON array of extension declaration objects:
[
{ "namespace": "math", "function": "sqrt",
"global_style": true, "receiver_style": false, "num_args": 1 },
{ "namespace": null, "function": "reverse",
"global_style": false, "receiver_style": true, "num_args": 1 },
{ "namespace": null, "function": "greet",
"global_style": true, "receiver_style": true, "num_args": 2 }
]
Note: the host is responsible for providing implementations at evaluation time. Extensions declared at compile time but not implemented by the host will produce a runtime error when the expression is evaluated.
Defining a CEL container
A CEL container acts as a namespace for type name resolution. When a container is set, unqualified type names are resolved relative to it first.
For example, with --container google.protobuf you can write Timestamp
instead of google.protobuf.Timestamp in your CEL expression:
ferricel build \
--expression 'Timestamp{seconds: 0}' \
--container google.protobuf \
--proto-descriptor descriptor.pb \
--output result.wasm
Using Proto
To use Protocol Buffer message types in a CEL expression, provide a compiled
descriptor set with --proto-descriptor. The flag can be repeated to merge
multiple descriptor files:
ferricel build \
--expression 'req.user_id != ""' \
--proto-descriptor api.pb \
--output result.wasm
Generate a descriptor set from your .proto files with protoc:
protoc --descriptor_set_out=api.pb --include_imports api.proto
Multiple descriptor files are merged automatically:
ferricel build \
--expression 'a.field == b.field' \
--proto-descriptor types_a.pb \
--proto-descriptor types_b.pb \
--output result.wasm
Running a CEL program compiled to Wasm
There are two ways to run a CEL program compiled to Wasm:
ferricel runCLI — simple command-line execution; does not support host extensions.ferricel-coreRust crate — more flexible; supports host extensions and custom configurations.
Using the ferricel CLI
Here’s a simple CEL program. Save it to a file named validate-balance.cel:
account.balance >= transaction.withdrawal
|| (account.overdraftProtection
&& account.overdraftLimit >= transaction.withdrawal - account.balance)
Compile it to a Wasm module:
ferricel build --expression-file validate-balance.cel -o validate-balance.wasm
Create a bindings file named validate-balance.bindings.json with the input data:
{
"account": {
"balance": 500,
"overdraftProtection": true,
"overdraftLimit": 1000
},
"transaction": {
"withdrawal": 700
}
}
Run the compiled Wasm module with the bindings:
ferricel run \
--bindings-file validate-balance.bindings.json \
validate-balance.wasm
Output:
true
Alternatively, pass bindings as an inline JSON string:
ferricel run \
--bindings-json '{"account":{"balance":500,"overdraftProtection":true,"overdraftLimit":0},"transaction":{"withdrawal":700}}' \
validate-balance.wasm
Output:
false
Using the ferricel-core Rust crate
The ferricel-core crate provides programmatic control over compilation and
evaluation. This is useful when you need to evaluate many expressions, integrate
CEL into a larger system, or use host extensions.
Basic evaluation
Compile a CEL expression and evaluate it with bindings:
#![allow(unused)]
fn main() {
use ferricel_core::{compiler, runtime};
let wasm = compiler::Builder::new()
.build()
.compile("x * 2 + 1")?;
let result = runtime::Builder::new()
.with_wasm(wasm)
.build()?
.eval(Some(r#"{"x": 10}"#))?;
assert_eq!(result, "21");
}
The eval method accepts JSON-encoded variable bindings and returns a
JSON-encoded result string.
Host extensions
Register host-provided functions that the CEL expression can call:
#![allow(unused)]
fn main() {
use ferricel_core::{compiler, runtime};
use ferricel_types::extensions::ExtensionDecl;
// Declare the extension at compile time
let abs_decl = ExtensionDecl {
namespace: None,
function: "abs".to_string(),
receiver_style: false,
global_style: true,
num_args: 1,
};
// Compile the CEL expression with the extension
let wasm = compiler::Builder::new()
.with_extension(abs_decl.clone())
.build()
.compile("abs(x)")?;
// Register the implementation at runtime
let result = runtime::Builder::new()
.with_extension(abs_decl, |args| {
let n = args[0].as_i64().unwrap_or(0);
Ok(serde_json::Value::Number(n.abs().into()))
})
.with_wasm(wasm)
.build()?
.eval(Some(r#"{"x": -42}"#))?;
assert_eq!(result, "42");
}
The extension declaration specifies the function signature at compile time (for validation), and the implementation is provided at runtime. The host is responsible for marshalling JSON values to and from the extension function.
Logging
Configure logging during evaluation with a custom logger and log level:
#![allow(unused)]
fn main() {
use ferricel_core::runtime;
use ferricel_types::LogLevel;
use slog::{Drain, Logger, o};
let decorator = slog_term::PlainSyncDecorator::new(std::io::stderr());
let drain = slog_term::FullFormat::new(decorator).build().fuse();
let logger = Logger::root(drain, o!());
let result = runtime::Builder::new()
.with_logger(logger)
.with_log_level(LogLevel::Debug)
.with_wasm(wasm)
.build()?
.eval(Some(r#"{"x": 10}"#))?;
}
Custom Wasmtime configuration
For advanced use cases, provide your own wasmtime::Engine with custom settings
(e.g. fuel metering, epoch interruption):
#![allow(unused)]
fn main() {
use ferricel_core::{compiler, runtime};
use wasmtime::{Config, Engine as WasmEngine};
let mut config = Config::new();
config.consume_fuel(true);
let wasm_engine = WasmEngine::new(&config)?;
let wasm = compiler::Builder::new().build().compile("1 + 1")?;
let result = runtime::Builder::new()
.with_engine(wasm_engine)
.with_wasm(wasm)
.build()?
.eval(None)?;
}
Protobuf bindings
For type-safe variable bindings that preserve full fidelity (bytes, uint,
timestamp, duration, etc.), use eval_proto with protobuf-encoded bindings:
let result = engine.eval_proto(&bindings_proto_bytes)?;
This avoids the JSON round-trip and preserves exact type information.
Memory management and performance
Important
CEL programs compiled to Wasm use a leaking allocator: memory is never deallocated during evaluation. This is not an issue in practice because CEL expressions are short-lived.
However, the consequence is that each evaluation starts with a fresh Wasm instance. This ensures no memory carries over between calls and prevents unbounded memory growth.
To optimize performance despite this, the Builder uses wasmtime::InstancePre
to pre-link the module at build time. This amortizes compilation cost:
Builder::build()parses the Wasm bytes once and pre-links all host functions.- Each
eval()call only pays the cost of instantiation.
For workloads with many evaluations, creating a single Engine and reusing it
across multiple eval() calls is the recommended pattern.
Compiler Architecture
Ferricel compiles CEL expressions into self-contained WebAssembly modules. The process involves two distinct Rust artifacts: the runtime and the compiler.
The Runtime
The runtime crate is a Rust library compiled to wasm32-unknown-unknown.
It provides the low-level functions that a compiled CEL program calls at
execution time, like memory allocation, value serialization, arithmetic helpers,
string operations, and so on.
Because the target is bare Wasm (no WASI, no OS), it is entirely self-contained.
The Compiler
ferricel-core contains the compiler. It parses the CEL source using the
parser that is part of the cel crate.
The parser produces a typed AST, which the compiler then traverses, emitting
WebAssembly instructions.
The WebAssembly instructions are produced using the walrus
crate.
Rather than generating a Wasm module from scratch, the compiler loads the
pre-embedded runtime.wasm module and injects the compiled CEL program into it.
A dead-code-elimination pass (based on walrus::passes::gc)
then removes any runtime functions the program does not call, keeping output
files small.
The result is a single .wasm file that is fully self-contained and can be
executed anywhere a WebAssembly runtime is available.