sacp-cbor 0.2.2

SACP-CBOR/1: strict deterministic CBOR validation and canonical encoding (hot-path optimized, no_std-capable).
Documentation

sacp-cbor

sacp-cbor is a strict, deterministic CBOR implementation of the SACP-CBOR/1 profile from the Synext Agent Control Protocol (SACP).

It is designed for hot-path validation (WebSocket frames, API request bodies) with:

  • allocation-free validation on success (validate_canonical)
  • allocation-free queries on validated bytes (borrowed views, no decoding)
  • deterministic map ordering enforcement (canonical CBOR key ordering, by encoded key bytes)
  • strict canonical integer/length encoding checks (shortest form)
  • strict numeric rules (safe integers, bignums via tags 2/3, float64-only, canonical NaN, forbid -0.0)
  • strict tag rules (only bignum tags 2 and 3)
  • no_std support (with optional alloc for owned types)

This crate intentionally keeps the core small and uncompromising. If bytes validate under SACP-CBOR/1, they are already canonical; therefore, for opaque payloads, semantic equality reduces to byte equality.

Status

  • Version: 0.1.0
  • License: MIT
  • MSRV: Rust 1.75

Features

Feature Default Meaning
std yes Implements std::error::Error for CborError.
alloc yes Enables owned AST types (CborValue, CborMap, CanonicalCbor) and canonical encoding.
sha2 yes Enables SHA-256 helpers for canonical CBOR bytes (sha256()).
serde no Enables serde-based conversions to/from canonical CBOR (to_vec, from_slice).

Note: serde currently requires std + alloc.

no_std usage

  • Validation-only (no allocation): disable default features:
sacp-cbor = { version = "0.1", default-features = false }
  • no_std + alloc (owned values + encoding): enable alloc:
sacp-cbor = { version = "0.1", default-features = false, features = ["alloc"] }
  • no_std + alloc + sha2:
sacp-cbor = { version = "0.1", default-features = false, features = ["alloc", "sha2"] }

Note: alloc requires an allocator provided by your environment.

Quick start

Validate SACP-CBOR/1 bytes (hot path)

use sacp_cbor::{validate_canonical, DecodeLimits};

fn handle_frame(bytes: &[u8]) -> Result<(), sacp_cbor::CborError> {
    let limits = DecodeLimits::for_bytes(bytes.len());
    let canonical = validate_canonical(bytes, limits)?;
    // At this point: SACP-CBOR/1 is satisfied, and `bytes` are canonical.
    let _stable_bytes = canonical.as_bytes();
    Ok(())
}

Decode into an owned AST (requires alloc)

use sacp_cbor::{decode_value, DecodeLimits};

let bytes = [0xa1, 0x61, 0x61, 0x01]; // {"a":1}
let v = decode_value(&bytes, DecodeLimits::for_bytes(bytes.len()))?;
assert_eq!(v.encode_canonical()?, bytes);
# Ok::<(), sacp_cbor::CborError>(())

Query canonical bytes without decoding

use sacp_cbor::{validate_canonical, DecodeLimits, PathElem};

// { "user": { "id": 42, "active": true } }
let bytes = [0xa1, 0x64, 0x75, 0x73, 0x65, 0x72, 0xa2, 0x62, 0x69, 0x64, 0x18, 0x2a, 0x66, 0x61, 0x63, 0x74, 0x69, 0x76, 0x65, 0xf5];
let canon = validate_canonical(&bytes, DecodeLimits::for_bytes(bytes.len()))?;

let path = [PathElem::Key("user"), PathElem::Key("id")];
let v = canon.at(&path)?.unwrap();
assert_eq!(v.int()?, 42);
# Ok::<(), sacp_cbor::CborError>(())
use sacp_cbor::{validate_canonical, DecodeLimits};

// { "a": 1, "b": 2, "c": 3 }
let bytes = [0xa3, 0x61, 0x61, 0x01, 0x61, 0x62, 0x02, 0x61, 0x63, 0x03];
let canon = validate_canonical(&bytes, DecodeLimits::for_bytes(bytes.len()))?;
let map = canon.root().map()?;

let out = map.get_many_sorted(["a", "b", "c"])?;
assert_eq!(out[1].unwrap().int()?, 2);
# Ok::<(), sacp_cbor::QueryError>(())

Serde encode/decode (requires serde + alloc)

use serde::{Deserialize, Serialize};
use sacp_cbor::{from_slice, to_vec, DecodeLimits};

#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct Msg {
    op: String,
    n: u64,
}

let msg = Msg {
    op: "ping".to_string(),
    n: 42,
};

let bytes = to_vec(&msg)?;
let decoded: Msg = from_slice(&bytes, DecodeLimits::for_bytes(bytes.len()))?;
assert_eq!(msg, decoded);
# Ok::<(), sacp_cbor::CborError>(())

Hash canonical bytes (requires sha2)

use sacp_cbor::{validate_canonical, DecodeLimits};

let bytes = [0xa1, 0x61, 0x61, 0x01];
let canon = validate_canonical(&bytes, DecodeLimits::for_bytes(bytes.len()))?;
let digest = canon.sha256();

API overview

  • validate_canonical(bytes, limits) -> CanonicalCborRef
  • validate(bytes, limits) -> ()
  • decode_value(bytes, limits) -> CborValue (feature alloc)
  • CborValue::encode_canonical() -> Vec<u8> (feature alloc)
  • cbor_equal(a, b) -> bool (feature alloc)
  • CanonicalCborRef::root() -> CborValueRef
  • CanonicalCborRef::at(path) -> Option<CborValueRef>
  • CborValueRef::{kind, map, array, get_key, get_index, at}
  • MapRef::{get, get_many_sorted, iter}
  • MapRef::get_many(keys) -> Vec<Option<CborValueRef>> (feature alloc)
  • to_vec<T: Serialize>(&T) -> Vec<u8> (feature serde + alloc)
  • from_slice<T: DeserializeOwned>(bytes, limits) -> T (feature serde + alloc)

Fuzzing

This repository includes cargo-fuzz targets under ./fuzz.

Prerequisites:

cargo install cargo-fuzz
rustup toolchain install nightly

Run:

cd fuzz
cargo +nightly fuzz run validate_roundtrip
cargo +nightly fuzz run validate_only

The fuzz targets:

  • validate arbitrary bytes under strict limits
  • when validation succeeds, decode and re-encode and assert roundtrip identity

Benchmarks

Criterion benchmarks are under ./benches.

Run:

cargo bench

Coverage (llvm-cov + grcov)

Prerequisites:

rustup component add llvm-tools-preview
cargo install grcov

Run the coverage script:

./scripts/coverage.sh

The HTML report is generated at ./coverage/index.html.

License

MIT. See LICENSE.