| name | rdf-canon |
| description | Canonicalize an RDF dataset with RDFC-1.0 (the W3C RDF Dataset Canonicalization, the URDNA2015 successor) using the opt-in sparq-canon crate — turn a set of oxrdf quads (or one graph's triples) into a deterministic, blank-node-relabelled canonical N-Quads string, or get just the canonical blank-node issuer map. Use when you need to hash, sign, diff, deduplicate, or content-address an RDF dataset, or to test two graphs for RDF-isomorphism (identical canonical form). Native + wasm; does not touch sparq-core's default build. |
sparq-canon — RDFC-1.0 dataset canonicalization
RDFC-1.0 (the W3C RDF Dataset
Canonicalization, the URDNA2015 successor) computes a deterministic form of an
RDF dataset: blank nodes are relabelled to c14n0, c14n1, … and the quads are
emitted in a stable code-point order. Two datasets are RDF-isomorphic iff their
canonical N-Quads serializations are byte-for-byte identical — the basis for
dataset hashing, signing, diffing, deduplication, and content-addressing.
sparq-canon is the opt-in public surface for this. Add it explicitly; it is
not in sparq's default build (sparq-core stays lean, the wasm artifact is
unchanged unless you pull this in). The RDFC-1.0 algorithm itself is the
maintained zkp-ld rdf-canon crate;
sparq-canon owns the single oxrdf-0.3 ↔ oxrdf-0.2 canonical-N-Quads bridge and
exposes a clean API over sparq's term model. sparq-zk depends on it.
Add the dependency
[dependencies]
sparq-canon = { path = "crates/sparq-canon" }
oxrdf = { version = "0.3", features = ["rdf-12"] }
Dataset API — quads in, canonical N-Quads out
The general case. Pass any slice of oxrdf::Quad (named graphs and the default
graph both work); get back the canonical N-Quads document.
use oxrdf::{Quad, NamedNode, NamedOrBlankNode, Term, Literal, GraphName, BlankNode};
let q = Quad::new(
NamedOrBlankNode::BlankNode(BlankNode::new("x").unwrap()),
NamedNode::new("http://example.org/p").unwrap(),
Term::Literal(Literal::new_simple_literal("v")),
GraphName::DefaultGraph,
);
let nquads: String = sparq_canon::canonicalize(&[q.clone()]).unwrap();
assert!(nquads.contains("_:c14n0"));
let map = sparq_canon::issued_identifiers(&[q]).unwrap();
assert_eq!(map.get("x").map(String::as_str), Some("c14n0"));
canonicalize_quads / issue_quads are aliases (the rdf_canon-style names).
Text in, text out (canonicalize_nquads)
When you already hold serialized RDF — e.g. across a language boundary — skip the
oxrdf term plumbing: canonicalize_nquads(&str) -> Result<String, _> parses an
N-Quads document and returns its canonical N-Quads (parse_nquads exposes the parse
alone). This is the seam the @jeswr/sparq RDF/JS Dataset uses for
isomorphism-aware toCanonical / equals / contains, surfaced over wasm as the
canonicalizeNQuads(nquads) binding behind sparq-wasm's opt-in canon feature.
let canon = sparq_canon::canonicalize_nquads(
"_:b0 <http://ex/p> _:b1 .\n_:b1 <http://ex/q> \"v\" .\n",
).unwrap();
assert!(canon.contains("_:c14n"));
Hash profiles and final digests
The spec default is SHA-256. To use the SHA-384 profile (or any
digest::Digest), use the *_with functions; Digest is re-exported so you do
not need a direct digest dependency:
let nq = sparq_canon::canonicalize_quads_with::<sha2::Sha384>(&dataset).unwrap();
To hash the exact standard canonical N-Quads bytes in one call, including the
final newline on a non-empty dataset, supply the final digest algorithm to
digest_quads_with:
let digest = sparq_canon::digest_quads_with::<sha2::Sha256>(&dataset).unwrap();
assert_eq!(digest.len(), 32);
Here D selects only the digest of the finished canonical document;
canonicalization still follows canonicalize_quads and its default RDFC-1.0
hash profile. Callers choose the digest crate and algorithm; sparq-canon adds
no default digest alias.
Single-graph API — one graph's triples
When you have one graph's content (a default-graph-only dataset), use the
triple-level API. It returns a CanonicalGraph with the sorted canonical lines
and the re-parsed canonical triples (line index = a stable total order — this is
exactly what the ZK per-graph commitment pipeline uses as the leaf order).
use sparq_canon::{canonicalize_triples, canonicalize_graph_content};
let canon = canonicalize_triples(&triples).unwrap();
let doc: String = canon.to_nquads();
let n = canon.lines.len();
let canon = canonicalize_graph_content(&graph).unwrap();
graph_triples(&graph) materializes a stored graph's triples as oxrdf::Triples
if you want them without canonicalizing.
Errors — fail closed
CanonError is #[non_exhaustive] (variants have been added ungated as the
crate grew — keep a wildcard arm) and has five variants:
TripleTerm — the dataset contains an RDF 1.2 triple term as an object; these
are outside W3C RDFC-1.0's data model, so the standard paths fail closed.
Enable the opt-in rdf12-triple-terms profile (below) to canonicalize them.
NestedBlankNode — a blank node occurs inside a triple term. Only the
opt-in profile's constrained *_ground_terms entry points (below) construct
it: they require blank-node-free (ground) triple terms and fail closed
otherwise. The variant itself is always present (not feature-gated), so
matching on CanonError is identical in both feature states.
TripleTermDepthExceeded — a triple term nests deeper than the crate-wide
MAX_TRIPLE_TERM_DEPTH bound (128). Only the opt-in profile's entry points
construct it: they pre-check depth iteratively before any recursive
descent (HNDQ gossip, relabelling, serialization — and oxrdf's own
Drop/Clone recurse), so adversarially deep nesting fails closed instead
of risking a stack overflow. Ungated, like NestedBlankNode.
Canonicalization(String) — rdf-canon rejected the dataset. This includes
the HNDQ call-limit guard: RDFC-1.0 has pathological-input blow-ups, so a
poison graph trips the limit and fails closed rather than running unbounded.
Bridge(String) — an internal serialize/parse error (should not occur for
well-formed RDFC-1.0-model input; surfaced rather than swallowed).
⚠️ Opt-in NON-STANDARD RDF 1.2 triple-term profile
OFF by default; NOT W3C RDFC-1.0. RDFC-1.0 is an RDF-1.1-only spec with no
notion of triple terms; their canonicalization is unsettled upstream
(w3c/rdf-star-wg#114). With the
feature OFF, behaviour is byte-identical to the standard surface (triple terms
still raise CanonError::TripleTerm; the W3C suite still passes).
Enable the cargo feature to opt in to a separate, clearly non-standard v2
profile. It natively re-implements the RDFC-1.0 algorithm over oxrdf 0.3 and
descends the Hash-N-Degree-Quads gossip into Term::Triple objects, so blank
nodes nested inside triple terms are relabelled to c14nN. On triple-term-free
input it is byte-identical to the standard path (asserted against every W3C suite
vector — the strongest correctness anchor).
sparq-canon = { path = "crates/sparq-canon", features = ["rdf12-triple-terms"] }
let nq = sparq_canon::canonicalize_rdf12(&dataset)?;
let cg = sparq_canon::canonicalize_triples_rdf12(&triples)?;
let m = sparq_canon::issue_dataset_rdf12(&dataset)?;
let nq384 = sparq_canon::canonicalize_rdf12_with::<sha2::Sha384>(&dataset)?;
let cg384 = sparq_canon::canonicalize_triples_rdf12_with::<sha2::Sha384>(&triples)?;
let m384 = sparq_canon::issue_dataset_rdf12_with::<sha2::Sha384>(&dataset)?;
let vc = sparq_canon::canonicalize_rdf12_ground_terms(&dataset)?;
let vcg = sparq_canon::canonicalize_triples_rdf12_ground_terms(&triples)?;
let vm = sparq_canon::issue_dataset_rdf12_ground_terms(&dataset)?;
let vgc = sparq_canon::canonicalize_graph_content_rdf12_ground_terms(&graph)?;
When to prefer the constrained *_ground_terms entry points. The common
credential/VC shape quotes or asserts ground content: triple terms with no
blank nodes inside. On that input the constrained wrappers are byte-identical
to the full profile (they are thin guard + delegate wrappers), but their
well-definedness does not rest on the novel nested-bnode HNDQ-descent
design — with every triple term ground, the run is exactly the RDFC-1.0
algorithm over an alphabet extended with opaque triple-term constants, and a
nested blank node is rejected (CanonError::NestedBlankNode) instead of
silently canonicalized under the extension. Top-level blank nodes (quad
subject/object/graph name) are ordinary RDFC-1.0 bnodes and stay fine. Use the
full canonicalize_rdf12 descent only when you actually need nested blank
nodes relabelled. Still non-standard either way: the canonical N-Quads carry
RDF-1.2 <<( … )>> tokens, which W3C RDFC-1.0 has no notion of.
Boundary / honesty: SHA-256 is the default; a *_with::<D: Digest> sibling of
each v2 entry point selects another hash (notably sha2::Sha384) for parity with
the standard path's canonicalize_quads_with. The non-generic entry points are
SHA-256 and byte-identical to before; a different D may produce a different
(still canonical, isomorphism-stable under that D) relabelling. Triple terms
appear only as objects in oxrdf 0.3 (a triple's subject is NamedOrBlankNode), so
nesting descends strictly through the object position; the HNDQ poison-graph call
limit still fails closed. This is a sparq-local extension, not a W3C standard
— do not represent its output as W3C RDFC-1.0.
Soundness audit + distinguishing-power regression (sq-mu1cd / sq-63g0). An
adversarial audit of the nested-bnode descent found the profile sound on every
constructed nested-bnode case (0 defects / 5 refuted suspicions): triple-term-
internal bnodes share one HNDQ position marker (object position + the outer
predicate), yet distinct nested structure never collapses, because the final
serialization re-renders the real Term::Triple with c14n labels and the issuer
map separates genuinely-distinct bnodes via the first-degree descent. The audit's
sharper non-isomorphic vectors are pinned as permanent regression tests
(tests/rdf12_triple_term_canon.rs §5) — asymmetric / anchored inner swap,
inner-subject vs inner-object leaf, depth-1 vs depth-2 nesting, self-loop
inner-subject vs inner-object — each first brute-force-proven non-isomorphic by
an independent oracle before asserting the canon differs, so a future
hash_n_degree_quads / hash_related_blank_node refactor that introduced a
false-equal would be caught. The whether-the-marker-needs-a-sub-discriminator
question is a spec-clarity / robustness matter, not a latent defect.
Conformance
Validated against the official W3C rdf-canon test
suite — all eval (canonical-output),
issued-map, and negative (poison-graph) cases, under both SHA-256 and SHA-384 —
through this crate's own public API. See crates/sparq-canon/tests/.
Comparative panel (bench/canon) — [FABLE-5] sq-hmd7l.16
bash bench/canon/run.sh --smoke drives the public API over the vendored W3C
suite via crates/sparq-canon/examples/canon_bench.rs (also a deterministic
gen-clique <n> poison generator) and asserts byte-identical canonical
N-Quads on the non-pathological set, then records poison-graph DoS outcomes
(ok/guard/capped/wrong/accepted) under a HARD per-graph wall-clock
cap. Full mode adds rdf-canonize (JS — the independent implementation; its
shipped maxWorkFactor=1 guard fail-closes on 18/64 approved evals, so parity
runs at its raised maxWorkFactor=3 posture, both postures recorded on the
poison panel) and the rdf-canon crate driven directly (at the default matching
pin that column measures this crate's bridge overhead — sparq-canon delegates
its algorithm to that same crate). Honesty notes + tunables:
bench/canon/README.md; first-read gap record: research/gap-canon-2026-07.md.
When NOT to use it
- You only need a syntactic serialization (N-Triples/N-Quads as-is): use the
oxrdf/oxttl serializers directly. Canonicalization is more expensive.
- The dataset has no blank nodes and a fixed order you already control:
canonicalization is still correct but buys you nothing.
Status
Verified against sparq-canon 0.1.0 source. The standard RDFC-1.0 path is
rdf-canon 0.15.3 (W3C-suite validated); sparq-canon is the single-sourced
bridge + public API. The opt-in, off-by-default rdf12-triple-terms profile
(sq-hslb [OPUS-4.8]) is a native RDFC-1.0 re-implementation extended to RDF 1.2
triple terms — non-standard (W3C RDFC-1.0 is RDF-1.1-only) — now with a
*_with::<D: Digest> hash-profile sibling on every entry point for SHA-384
parity (sq-5i1d [OPUS-4.8]) and a constrained ground-triple-term
(error-on-nested-bnode) *_ground_terms wrapper family for the common
credential/VC case (sq-iaxd [FABLE-5]). The canonicalize_nquads / parse_nquads text seam
and the sparq-wasm opt-in canon feature (canonicalizeNQuads binding for the
@jeswr/sparq RDF/JS Dataset) are sq-1dd5t [OPUS-4.8]; that wasm consumer pulls
sparq-canon with default-features = false (the crate now disables
sparq-core's default parallel and re-enables it via its own default parallel
feature, so native builds are byte-identical and the wasm build drops rayon).
publish = false, non-default workspace member — nothing in sparq's default graph
depends on it, so the default build and lean wasm artifact are byte-identical with
or without it.