atomvm-cbor
A compact RFC 8949 CBOR encoder and decoder for AtomVM and constrained Erlang runtimes. It is pure Erlang, has no runtime dependencies, NIFs, or ports, and preserves the public {text, Binary} and {map, Pairs} representations.
Version 0.2.0 is the first Hex.pm release. Public Git tag v0.1.1 is the reproducible source and benchmark baseline and was never published to Hex.
Installation
Rebar3 projects use the OTP application name avm_cbor and Hex package name atomvm_cbor:
{deps, [
{avm_cbor, "~> 0.2.0", {pkg, atomvm_cbor}}
]}.
Mix projects use:
{:avm_cbor, "~> 0.2.0", hex: :atomvm_cbor}
Capabilities
- All CBOR major types, UTF-8 validation, semantic-tag passthrough, and half/32/64-bit float decode.
- Definite and bounded indefinite-length strings, arrays, and maps.
- Preferred serialization and deterministic encoding/validation under explicit options.
- Duplicate-map-key rejection and encoded-key ordering checks in deterministic decode.
- Full decode, pull-based continuation decode, complete-input decode, CBOR sequence decode, and deferred partial decode.
- Depth, node, input-byte, per-string, cumulative-string, and partial-string limits.
- Structured errors for malformed, truncated, unsupported, and resource-bound inputs.
- OTP 25+ and AtomVM 0.6.6 compatibility.
Attached-device benchmarks
Lower timings are better. The common encode/decode results compare the exact
public v0.1.1 baseline with 0.2.0 using the same CBOR payload and AtomVM
0.6.6 harness on each device. See the benchmark methodology
and the full 0.2.0 validation report for complete
provenance, hardware details, soak results, and reproduction guidance.
| Board | Function | v0.1.1 µs | 0.2.0 µs | Change |
|---|---|---|---|---|
| ESP32-S3 (8 MB PSRAM) | encode/1 | 8705.82 | 7120.54 | 18.21% faster |
| ESP32-S3 (8 MB PSRAM) | decode/1 | 8508.98 | 8683.68 | 2.05% slower |
| RP2040 (16 MB flash) | encode/1 | 7248.56 | 5118.92 | 29.38% faster |
| RP2040 (16 MB flash) | decode/1 | 7105.50 | 6400.78 | 9.92% faster |
| WaveShare ESP32-S3-DEV-KIT-N32R16V | encode/1 | 6080.32 | 4064.56 | 33.15% faster |
| WaveShare ESP32-S3-DEV-KIT-N32R16V | decode/1 | 5853.32 | 4600.30 | 21.41% faster |
The partial/deferred APIs are new in 0.2.0, so no v0.1.1 measurements
exist for them.
| Function | ESP32-S3 0.2.0 µs | RP2040 0.2.0 µs | WaveShare N32R16V 0.2.0 µs |
|---|---|---|---|
partial_decode/1 | 13109.00 | 9875.48 | 7518.24 |
partial_decode/2 | 13326.80 | 9928.18 | 7570.60 |
partial_deep_decode/1 | 10805.88 | 8243.96 | 5911.32 |
partial_value_bytes/1 | 767.53 | 579.66 | 562.63 |
partial_contents/1 | 959.06 | 506.69 | 607.53 |
partial_skip/1 | 380.07 | 228.66 | 295.30 |
partial_type/1 | 382.08 | 229.65 | 295.48 |
partial_count/1 | 381.27 | 229.65 | 296.33 |
partial_tag/1 | 438.55 | 253.83 | 360.91 |
partial_size/1 | 455.71 | 260.95 | 382.75 |
partial_offset/1 | 379.89 | 227.56 | 296.28 |
partial_length/1 | 382.62 | 234.45 | 295.84 |
The older ESP32-S3 decode/1 regression is measured and reproducible. It is
accepted under the unchanged 5% release threshold because 0.2.0 retains the
new fail-closed node and string-byte accounting on this general decode path.
The same workload improves on RP2040 and WaveShare, so no single hardware or
security-check cause is claimed. The full report includes the complete
technical interpretation and maximum-bound testing.
Public term representation
| CBOR value | Erlang value |
|---|---|
| unsigned or negative integer | integer |
| byte string | binary |
| text string | {text, Binary} |
| array | list |
| map | {map, [{Key, Value}, ...]} |
| semantic tag | {tag, Number, Value} |
| boolean/null/undefined | true, false, null, undefined |
| simple value | {simple, Number} |
| float | float |
Core API
avm_cbor:decode(Binary).
avm_cbor:decode(Binary, Options).
avm_cbor:decode_all(Binary, Options).
avm_cbor:decode_sequence(Binary, Options).
avm_cbor:encode(Value, Options).
decode/1,2 consumes one value and returns {ok, Value, Rest}. decode_all/1,2 requires a complete input. decode_sequence/1,2 returns all complete sequence items and retains a truncated final item as Rest.
Pull-based continuation decode
{ok, State0} = avm_cbor:decode_start(Binary, Options),
case avm_cbor:decode_continue(State0, Budget) of
{done, Value, Rest} -> use(Value, Rest);
{more, State1} -> schedule_next(State1);
{error, Reason} -> reject(Reason)
end.
Budget is a positive bound on explicit parser transitions. The returned state
is immutable and opaque. The decoder never sleeps or yields; event-loop pacing
belongs to the caller. This is not a streaming-input API: Binary must already
exist when decode_start/2 is called.
Partial and deferred decode
partial_decode/1,2 validates and measures one value without eagerly constructing nested Erlang terms. It returns an opaque descriptor; callers must use the public accessors rather than matching its internal shape.
{ok, Descriptor, Rest} = avm_cbor:partial_decode(Binary),
Type = avm_cbor:partial_type(Descriptor),
Length = avm_cbor:partial_length(Descriptor),
{ok, Value} = avm_cbor:partial_deep_decode(Descriptor).
Available accessors are partial_value_bytes/1, partial_deep_decode/1, partial_skip/1, partial_type/1, partial_count/1, partial_tag/1, partial_size/1, partial_offset/1, partial_length/1, and partial_contents/1.
Options and limits
| Option | Default | Partial default | Purpose |
|---|---|---|---|
max_depth | 128 | 128 | nesting depth |
max_items | 4096 | 64 | global node/work budget |
max_bytes | 1048576 | 1048576 | input/output bytes |
max_string_bytes | 65536 | 65536 | one string or chunk |
max_total_string_bytes | 1048576 | 1048576 | cumulative decoded string bytes |
max_string_size | not used | 8192 | partial byte/text content |
allow_floats | true | true | float support |
allow_simple | true | true | simple values |
allow_tags | true | true | semantic tags |
allow_indefinite | true | true | indefinite-length values |
preferred | false | false | preferred serialization checks/output |
deterministic | false | false | deterministic encoding and strict decode validation |
Options are normalized once into fixed internal state. Recursive paths do not repeatedly scan or rebuild option proplists.
deterministic=true rejects indefinite-length input, non-preferred width choices, duplicate map keys, and map keys not ordered by their original encoded bytes. ble_options/0 provides a stricter device-oriented profile.
Treat options as trusted application policy. Do not allow a network client to
raise them. Enforce the same or a smaller byte ceiling at transport ingress
before buffering the complete request, use a lower fixed profile on targets
that cannot allocate the defaults, and require empty trailing Rest for
protocols that permit exactly one item.
Large definite containers made entirely of preferred one-byte unsigned values use a bounded fixed-cost path. It is independently capped at 4095 direct nodes, so caller-raised limits cannot turn it into an unbounded speculative allocation. Mixed, malformed, deterministic, nested, multi-byte, or larger containers use the general bounded decoder and retain the same errors.
Documentation and validation
- API guide
- Decoder policy and security limits
- Pinned AtomVM memory evidence
- Benchmark methodology
- Changelog
Run the public validation suite:
scripts/release-check.sh 0.2.0
Run the reproducible host benchmark:
scripts/bench.sh
Benchmark values depend on the host, OTP version, and architecture. Published comparisons are measured context, not guarantees for other devices.
License
MIT. See LICENSE.