DocShell.Json.Canonical (DocShell v2.0.0)

Copy Markdown View Source

Computes reproducible JSON bytes and content digests at serialization boundaries.

Every artifact DocShell writes uses this encoding, and every collection digest is computed over it. Objects are sorted by UTF-8 key bytes; array order and native scalar types remain unchanged; no insignificant whitespace is emitted. The encoding preserves the original DocShell collection digest format. It is not an implementation of RFC 8785 (in particular, numbers use Jason's encoding).

Jason's JSON string escaping uses uppercase hexadecimal digits for control escapes. Its numbers also preserve distinctions such as 1.0, negative zero and integers outside JavaScript's exact range. Readers must retain original canonical bytes when their decoded number type loses those distinctions; re-encoding a JavaScript value is not equivalent proof.

One pass

Native JSON values are validated and encoded in a single walk, so a large artifact is never encoded, decoded and re-encoded. A subtree that is not native JSON — a struct with a Jason.Encoder implementation, or a map with atom keys — is encoded through Jason with strict object keys, decoded with duplicate-key rejection, and canonicalized in place. Protocol encoders keep their wire representation and atom/string key collisions fail before any information is discarded. The result is byte-identical to encoding the whole value through Jason first; only the subtrees that need the round trip pay for it.

When the fast walk meets a value it cannot prove valid, such as an invalid UTF-8 string or a tuple, the whole value takes the protocol path so the returned error is the one Jason reports.

encode_to_iodata/1 returns iodata that can be hashed and written without ever being flattened into one binary. digest_iodata/1 hashes such bytes, so a writer that already holds an artifact's canonical encoding does not encode it a second time to compute its digest.

All functions return tagged errors, including exceptions from host encoders. They neither coerce unsupported metadata nor hide encoding errors in inspected strings. Use DocShell.Json.normalize/1 when coercion is explicitly intended.

Summary

Types

Values supported by Jason, subject to UTF-8 and unique encoded keys.

Functions

Hashes canonical JSON bytes as a lowercase, prefixed SHA-256 digest.

Hashes bytes already produced by encode_to_iodata/1.

Encodes a value to compact canonical JSON, rejecting ambiguous object keys.

Encodes a value to compact canonical JSON iodata.

Types

encodable()

@type encodable() ::
  atom()
  | number()
  | String.t()
  | struct()
  | [encodable()]
  | %{optional(atom() | String.t()) => encodable()}

Values supported by Jason, subject to UTF-8 and unique encoded keys.

Functions

digest(value)

@spec digest(term()) :: {:ok, String.t()} | {:error, term()}

Hashes canonical JSON bytes as a lowercase, prefixed SHA-256 digest.

digest_iodata(iodata)

@spec digest_iodata(iodata()) :: String.t()

Hashes bytes already produced by encode_to_iodata/1.

The caller is responsible for passing canonical bytes; this function does not parse or re-encode them.

encode(value)

@spec encode(term()) :: {:ok, binary()} | {:error, term()}

Encodes a value to compact canonical JSON, rejecting ambiguous object keys.

encode_to_iodata(value)

@spec encode_to_iodata(term()) :: {:ok, iodata()} | {:error, term()}

Encodes a value to compact canonical JSON iodata.

The bytes are identical to encode/1. Prefer this function when the result is written to a file or hashed, since neither needs a contiguous binary.