Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 .wasm module produced by it
  • ferricel-core: the pure Rust crate used by ferricel CLI. 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

FeatureStatus
Integer arithmetic (+, -, *, /, %) with overflow detectionSupported
Floating-point arithmeticSupported
Unsigned integer (uint) arithmeticSupported
Boolean logic (&&, ||, !) with short-circuit evaluationSupported
Comparison operators (==, !=, <, <=, >, >=)Supported
String operations (+, size, contains, startsWith, endsWith, matches)Supported
Bytes operationsSupported
List literals, indexing, membership (in)Supported
Map literals, field access, indexingSupported
Conditional expressions (? :)Supported
Null handling and null propagationSupported
Type conversions (int(), uint(), double(), string(), bytes(), bool())Supported
Timestamp and Duration typesSupported
Timestamp/Duration arithmetic and field accessorsSupported
size() functionSupported
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 accessSupported
Protobuf wrapper type semantics (google.protobuf.*Value)Supported
dyn() type erasureSupported
type() introspectionSupported

cel-go Extension Libraries

ExtensionFunctionsStatus
Bindingscel.bind(var, init, body)Supported
Encodersbase64.encode, base64.decodeSupported
Mathmath.greatest, math.leastSupported
math.bitOr, math.bitAnd, math.bitXor, math.bitNotSupported
math.bitShiftLeft, math.bitShiftRightSupported
math.ceil, math.floor, math.round, math.truncSupported
math.abs, math.signSupported
math.isInf, math.isNaN, math.isFiniteSupported
math.sqrtSupported
StringscharAt, indexOf, lastIndexOfSupported
lowerAscii, upperAscii, trimSupported
replace, split, substring, joinSupported
reverse, strings.quoteSupported
format (string interpolation)Supported
Listsslice, flatten, distinct, reverseSupported
sort, sortBySupported
first, lastSupported
lists.range(n)Supported
Setssets.contains, sets.equivalent, sets.intersectsSupported
TwoVarComprehensionsall(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
Regexregex.replace, regex.extract, regex.extractAllSupported
Protosproto.getExt, proto.hasExtNot supported

Kubernetes CEL Extensions

Ferricel also supports the Kubernetes CEL validation libraries:

ExtensionStatus
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 SuiteSuccessfulFailedSkipped
basic4120
bindings_ext800
block_ext18811
comparisons40600
conversions10900
encoders_ext400
fp_math3000
integer_math6400
lists3900
logic3000
macros24600
macros4400
math_ext19900
namespace1130
network_ext6900
optionals7000
parse1287417
string_ext21600
string5100
timestamps7600
type_deduction17129
Total16768857

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.

ParameterTypeDescription
lenusizeNumber 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.

ParameterTypeDescription
leveli32Log level threshold (see table below). Values outside the valid range are clamped.

The log levels are:

ValueLevel
0Debug
1Info (default)
2Warn
3Error

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.

ParameterTypeDescription
bindingsi64Packed 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.

ParameterTypeDescription
bindingsi64Packed 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.

ParameterTypeDescription
ptri32Offset in Wasm linear memory of a UTF-8 JSON-encoded LogEvent object.
leni32Byte 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).

ParameterTypeDescription
packedi64Packed 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.

ParameterTypeDescription
requesti64Packed 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:

SegmentDescription
namespaceOptional dot-separated namespace prefix (e.g. math in math.sqrt).
functionFunction name (the last dot-separated segment).
styleOne of global, receiver, or both (see below).
arityTotal number of arguments the host receives, including the receiver for receiver-style calls.

Calling styles:

StyleInvocation form
globalfunc(args) or ns.func(args)
receivervalue.func(extra_args) — receiver is always args[0]
bothBoth 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 run CLI — simple command-line execution; does not support host extensions.
  • ferricel-core Rust 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.