RFC 9530 content/representation digests and preference fields, using OTP hashes.
compute/3 hashes a retained RequestSeal.Body without re-encoding its bytes.
init/3, update/2, and finish/1 support caller-fed binary chunks without
retaining content, reading handles, or starting processes. Results are
Structured Fields dictionaries, serialized with serialize/1. check/3 and
check_stream/3 compare computed checksums with a Message's Content-Digest or
Repr-Digest field.
Retained content is bounded at 16,777,216 bytes (16 MiB): compute/3 and
check/3 cannot raise that hard limit, bounding work on already retained
content. Only caller-fed streams may explicitly opt in to a higher :max_bytes
through init/3: incremental hash state uses constant memory and retains no
chunks. The caller owns that larger work budget. State is caller-owned and
immutable; use each returned state and finish only at EOF. Finishing does not
prove transport completion; the caller supplies that boundary.
Hash HTTP content after transfer framing removal but before content decoding. Gzip bytes remain gzip bytes. A range's content is only the transmitted range; a HEAD response's content is empty. Representation hashing always uses the complete selected representation, including content encoding (Sections 2–3). The caller selects that representation using HTTP method, status, metadata, and resource semantics; this library never reconstructs it from partial content. No unavailable body substitutes for empty bytes.
Every supported checksum present must match. Before dictionary deduplication,
checks reject a supported algorithm appearing more than once across the combined
occurrences in the selected section with :conflicting_digest, even if its
checksum values agree. Unknown and deprecated algorithms
are parsed but never computed; success reports only their count. They cannot
establish a match alone. A stream must have computed every supported algorithm
in the field. A successful check proves checksum agreement only,
with no signature coverage, authentication, identity, or authorization claim.
Legacy Digest fields never substitute for these fields.
parse/1 and serialize/1 use the RFC 8941 dictionary/byte-sequence schema
mandated by RFC 9530; known checksum lengths are checked. Preferences are
hints, with 0 meaning unacceptable (Section 4); this module selects no
algorithm from them. Parameters and duplicate keys follow Structured
Fields rules (last value, first position), except supported checksum algorithms
in check/3 and check_stream/3, which reject any repeat with :conflicting_digest.
Inspection hides hash contexts.
Summary
Functions
Recompute the selected field from retained content or explicit representation.
Check hashes accumulated through caller EOF against a Message digest field.
Compute SHA-256/SHA-512 checksums from retained exact body bytes.
Finalize checksums as an ordered Structured Fields dictionary at caller EOF.
Initialize incremental hashes for an explicit semantic kind and algorithms.
Parse an RFC 9530 integrity dictionary; parameters retain RFC semantics.
Parse an RFC 9530 preference dictionary with integer weights in 0..10.
Read an explicit Want-Content-Digest or Want-Repr-Digest field/section.
Serialize a digest dictionary with known algorithm checksum-size validation.
Serialize an RFC 9530 preference dictionary with weight validation.
Hash one binary content chunk, rejecting overflow before cryptographic work.
Types
@type failure() :: {:error, RequestSeal.Digest.Error.t() | RequestSeal.StructuredFields.Error.t()}
@type t() :: %RequestSeal.Digest{ bytes: non_neg_integer(), hashes: [{binary(), reference()}], kind: :content | :representation, max_bytes: non_neg_integer() }
Functions
@spec check(RequestSeal.Message.t(), :content | :representation, keyword()) :: {:ok, map()} | failure()
Recompute the selected field from retained content or explicit representation.
:content checks Content-Digest against the Message's retained body.
:representation checks Repr-Digest against the :representation option, a
retained Body containing the entire selected representation, even when it
equals content. Only supported algorithms in the field are computed.
Parameters
message- a validRequestSeal.Message.kind-:contentor:representation.
Options
Unknown or duplicate options reject.
:section-:headers(default) or:trailers; sections never merge. A trailer must be observed (a list) before checking it.:max_bytes- integer in 0..16,777,216 bounding the hashed bytes; default 16,777,216.:representation- a retainedRequestSeal.Body; required for:representationand rejected for:content.
Returns
{:ok, %{kind: kind, bytes: count, checked: names, unsupported: count}}- every supported checksum matched.:checkedlists the supported algorithm names in field order;:unsupportedcounts other algorithm names.{:error, %RequestSeal.Digest.Error{}}- reasons this function can return::invalid_message,:invalid_kind,:invalid_options,:representation_required,:invalid_body,:body_unavailable,:conflicting_digest,:invalid_digest_length,:unsupported_algorithm(no supported algorithm in the field),:limit, and:mismatch. Meanings:RequestSeal.Digest.Error.{:error, %RequestSeal.StructuredFields.Error{}}-:unavailable_section,:missing_field,:limit,:syntax,:revision_type, or:schema_type. Meanings:RequestSeal.StructuredFields.Error.
Examples
iex> {:ok, request} = RequestSeal.Message.request("POST", "https://docs.example/notes", [], ~s({"hello": "world"}), digest: ["sha-256"])
iex> RequestSeal.Digest.check(request, :content)
{:ok, %{kind: :content, bytes: 18, checked: ["sha-256"], unsupported: 0}}
iex> RequestSeal.Digest.check(%{request | body: %{request.body | bytes: ~s({"hello": "World"})}}, :content)
{:error, %RequestSeal.Digest.Error{reason: :mismatch}}
iex> RequestSeal.Digest.check(request, :representation)
{:error, %RequestSeal.Digest.Error{reason: :representation_required}}A representation check reads Repr-Digest and the explicit representation body:
iex> {:ok, representation} = RequestSeal.Body.new(%{state: :retained, bytes: ~s({"hello": "world"})})
iex> {:ok, response} = RequestSeal.Message.response(200, [{"Repr-Digest", "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:"}], nil)
iex> RequestSeal.Digest.check(response, :representation, representation: representation)
{:ok, %{kind: :representation, bytes: 18, checked: ["sha-256"], unsupported: 0}}
@spec check_stream(t(), RequestSeal.Message.t(), keyword()) :: {:ok, map()} | failure()
Check hashes accumulated through caller EOF against a Message digest field.
The state's kind selects Content-Digest (:content) or Repr-Digest
(:representation). The state must have computed every supported algorithm in
the field; extra computed algorithms are ignored. The Message body is not read.
Parameters
state- aRequestSeal.Digeststate fed through the caller's EOF.message- a validRequestSeal.Message.
Options
:section-:headers(default) or:trailers; the only option. A trailer must be observed (a list) before checking it.
Returns
{:ok, %{kind: kind, bytes: count, checked: names, unsupported: count}}- as incheck/3.{:error, %RequestSeal.Digest.Error{}}- reasons this function can return::invalid_state,:invalid_options,:conflicting_digest,:invalid_digest_length,:unsupported_algorithm,:uncomputed_algorithm, and:mismatch. Meanings:RequestSeal.Digest.Error.{:error, %RequestSeal.StructuredFields.Error{}}-:invalid_message,:unavailable_section,:missing_field,:limit,:syntax,:revision_type, or:schema_type. Meanings:RequestSeal.StructuredFields.Error.
Examples
iex> {:ok, body} = RequestSeal.Body.new(%{state: :streaming, source: make_ref()})
iex> {:ok, transport} = RequestSeal.TransportFacts.new(%{})
iex> {:ok, field} = RequestSeal.FieldOccurrence.new(%{name: "Content-Digest", value: "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:", section: :headers})
iex> {:ok, response} = RequestSeal.Message.new(%{kind: :response, status: 200, fields: [field], trailers: :pending, body: body, transport: transport})
iex> {:ok, state} = RequestSeal.Digest.init(:content, ["sha-256"])
iex> {:ok, state} = RequestSeal.Digest.update(state, ~s({"hello": "world"}))
iex> RequestSeal.Digest.check_stream(state, response)
{:ok, %{kind: :content, bytes: 18, checked: ["sha-256"], unsupported: 0}}
iex> RequestSeal.Digest.check_stream(state, response, section: :trailers)
{:error, %RequestSeal.StructuredFields.Error{reason: :unavailable_section}}
iex> {:ok, other} = RequestSeal.Digest.init(:content, ["sha-512"])
iex> RequestSeal.Digest.check_stream(other, response)
{:error, %RequestSeal.Digest.Error{reason: :uncomputed_algorithm}}
@spec compute(RequestSeal.Body.t(), [binary()], keyword()) :: {:ok, RequestSeal.StructuredFields.Value.t()} | failure()
Compute SHA-256/SHA-512 checksums from retained exact body bytes.
Parameters
body- a validRequestSeal.Bodyin the:retainedstate. Its bytes are hashed as they are, without re-encoding.algorithms- a nonempty list of unique names drawn from"sha-256"and"sha-512"; the result keeps this order.
Options
:max_bytes- integer in 0..16,777,216 bounding the hashed bytes; default 16,777,216. A larger value rejects; useinit/3for a larger streaming budget.
Returns
{:ok, %RequestSeal.StructuredFields.Value{type: :dictionary}}- as infinish/1.{:error, %RequestSeal.Digest.Error{}}- reasons this function can return::invalid_body,:body_unavailable(streaming, unavailable, or consumed),:invalid_options,:unsupported_algorithm, and:limit. Meanings:RequestSeal.Digest.Error.
Examples
iex> {:ok, body} = RequestSeal.Body.new(%{state: :retained, bytes: ~s({"hello": "world"})})
iex> {:ok, dictionary} = RequestSeal.Digest.compute(body, ["sha-256"])
iex> RequestSeal.Digest.serialize(dictionary)
{:ok, "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:"}
iex> RequestSeal.Digest.compute(body, ["sha-256"], max_bytes: 16_777_217)
{:error, %RequestSeal.Digest.Error{reason: :invalid_options}}
iex> {:ok, unavailable} = RequestSeal.Body.new(%{state: :unavailable})
iex> RequestSeal.Digest.compute(unavailable, ["sha-256"])
{:error, %RequestSeal.Digest.Error{reason: :body_unavailable}}
@spec finish(t()) :: {:ok, RequestSeal.StructuredFields.Value.t()} | failure()
Finalize checksums as an ordered Structured Fields dictionary at caller EOF.
Finishing does not prove transport completion. The state stays usable; finishing it again returns the same checksums.
Parameters
state- aRequestSeal.Digeststate.
Returns
{:ok, %RequestSeal.StructuredFields.Value{type: :dictionary}}- one{name, item}member per algorithm ininit/3order; each item value is{:bytes, checksum}.{:error, %RequestSeal.Digest.Error{reason: :invalid_state}}- the only reason. Meanings:RequestSeal.Digest.Error.
Examples
iex> {:ok, state} = RequestSeal.Digest.init(:content, ["sha-256"])
iex> {:ok, state} = RequestSeal.Digest.update(state, ~s({"hello": ))
iex> {:ok, state} = RequestSeal.Digest.update(state, ~s("world"}))
iex> {:ok, dictionary} = RequestSeal.Digest.finish(state)
iex> RequestSeal.Digest.serialize(dictionary)
{:ok, "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:"}
iex> RequestSeal.Digest.finish(%{state | bytes: -1})
{:error, %RequestSeal.Digest.Error{reason: :invalid_state}}
Initialize incremental hashes for an explicit semantic kind and algorithms.
Feed chunks with update/2, then call finish/1 or check_stream/3 at the
caller's EOF.
Parameters
kind-:contentor:representation; selects the fieldcheck_stream/3compares against (Content-Digest or Repr-Digest).algorithms- a nonempty list of unique names drawn from"sha-256"and"sha-512"; the result keeps this order.
Options
:max_bytes- nonnegative integer bounding the total bytes accepted byupdate/2; default 16,777,216. It may exceed 16 MiB to explicitly opt in to a larger streaming work budget.
Returns
{:ok, %RequestSeal.Digest{}}- immutable hash state withbytes: 0.{:error, %RequestSeal.Digest.Error{}}- reasons this function can return::invalid_kind,:invalid_options(not a keyword list, unknown or duplicate keys, or a negative or non-integer:max_bytes), and:unsupported_algorithm. Meanings:RequestSeal.Digest.Error.
Examples
iex> {:ok, state} = RequestSeal.Digest.init(:content, ["sha-256", "sha-512"], max_bytes: 1024)
iex> {state.kind, state.bytes, state.max_bytes}
{:content, 0, 1024}
iex> RequestSeal.Digest.init(:content, ["md5"])
{:error, %RequestSeal.Digest.Error{reason: :unsupported_algorithm}}
iex> RequestSeal.Digest.init(:content, ["sha-256", "sha-256"])
{:error, %RequestSeal.Digest.Error{reason: :unsupported_algorithm}}
@spec parse(binary()) :: {:ok, RequestSeal.StructuredFields.Value.t()} | failure()
Parse an RFC 9530 integrity dictionary; parameters retain RFC semantics.
Every member must be a byte sequence. Known algorithms ("sha-256", 32 bytes;
"sha-512", 64 bytes) have their checksum length checked; other algorithm
names are kept unchecked. Duplicate keys follow the Structured Fields
last-value, first-position rule.
Parameters
wire- the field value bytes (at most 65,536).
Returns
{:ok, %RequestSeal.StructuredFields.Value{type: :dictionary}}.{:error, %RequestSeal.StructuredFields.Error{}}-:syntax,:limit,:revision_type, or:schema_type. Meanings:RequestSeal.StructuredFields.Error.{:error, %RequestSeal.Digest.Error{reason: :invalid_digest_length}}. Meanings:RequestSeal.Digest.Error.
Examples
iex> {:ok, dictionary} = RequestSeal.Digest.parse("sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:, md5=:AAAA:")
iex> Enum.map(dictionary.value, fn {name, _item} -> name end)
["sha-256", "md5"]
iex> RequestSeal.Digest.parse("sha-256=:AAAA:")
{:error, %RequestSeal.Digest.Error{reason: :invalid_digest_length}}
iex> RequestSeal.Digest.parse("sha-256=42")
{:error, %RequestSeal.StructuredFields.Error{reason: :schema_type}}
@spec parse_preferences(binary()) :: {:ok, RequestSeal.StructuredFields.Value.t()} | failure()
Parse an RFC 9530 preference dictionary with integer weights in 0..10.
Weights are hints; 0 means unacceptable. Algorithm names are not restricted.
Parameters
wire- the Want-Content-Digest or Want-Repr-Digest field value bytes (at most 65,536).
Returns
{:ok, %RequestSeal.StructuredFields.Value{type: :dictionary}}- members carry{:integer, weight}items.{:error, %RequestSeal.StructuredFields.Error{}}-:syntax,:limit,:revision_type, or:schema_type. Meanings:RequestSeal.StructuredFields.Error.{:error, %RequestSeal.Digest.Error{reason: :invalid_preference}}- a weight outside 0..10. Meanings:RequestSeal.Digest.Error.
Examples
iex> {:ok, preferences} = RequestSeal.Digest.parse_preferences("sha-512=3, sha-256=10, md5=0")
iex> Enum.map(preferences.value, fn {name, item} -> {name, item.value} end)
[{"sha-512", {:integer, 3}}, {"sha-256", {:integer, 10}}, {"md5", {:integer, 0}}]
iex> RequestSeal.Digest.parse_preferences("sha-256=11")
{:error, %RequestSeal.Digest.Error{reason: :invalid_preference}}
@spec preferences(RequestSeal.Message.t(), binary(), :headers | :trailers) :: {:ok, RequestSeal.StructuredFields.Value.t()} | failure()
Read an explicit Want-Content-Digest or Want-Repr-Digest field/section.
Matching occurrences in the selected section are combined in order, as in
RequestSeal.StructuredFields.parse_field/5, then parsed as in
parse_preferences/1.
Parameters
message- a validRequestSeal.Message.name-"Want-Content-Digest"or"Want-Repr-Digest", any case.section-:headers(default) or:trailers.
Returns
{:ok, %RequestSeal.StructuredFields.Value{type: :dictionary}}.{:error, %RequestSeal.Digest.Error{}}-:invalid_options(another section),:invalid_field(another field name),:invalid_preference, or:invalid_message(an invalid message; from 0.5.0 aRequestSeal.Digest.Error, previously aRequestSeal.StructuredFields.Error). Meanings:RequestSeal.Digest.Error.{:error, %RequestSeal.StructuredFields.Error{}}-:unavailable_section,:missing_field,:limit,:syntax,:revision_type, or:schema_type. Meanings:RequestSeal.StructuredFields.Error.
Examples
iex> {:ok, request} = RequestSeal.Message.request("GET", "https://builds.example/artifacts/118", [{"Want-Content-Digest", "sha-512=3, sha-256=10"}], nil)
iex> {:ok, preferences} = RequestSeal.Digest.preferences(request, "want-content-digest")
iex> Enum.map(preferences.value, fn {name, item} -> {name, item.value} end)
[{"sha-512", {:integer, 3}}, {"sha-256", {:integer, 10}}]
iex> RequestSeal.Digest.preferences(request, "Want-Repr-Digest")
{:error, %RequestSeal.StructuredFields.Error{reason: :missing_field}}
iex> RequestSeal.Digest.preferences(request, "Accept")
{:error, %RequestSeal.Digest.Error{reason: :invalid_field}}
@spec serialize(RequestSeal.StructuredFields.Value.t()) :: {:ok, binary()} | failure()
Serialize a digest dictionary with known algorithm checksum-size validation.
Parameters
value- aRequestSeal.StructuredFields.Valuedictionary whose members are{:bytes, checksum}items, such as the result ofcompute/3orfinish/1.
Returns
{:ok, wire}- canonical field value bytes;""for an empty dictionary (omit the field).{:error, %RequestSeal.StructuredFields.Error{}}-:invalid_value,:schema_type,:revision_type, or:limit. Meanings:RequestSeal.StructuredFields.Error.{:error, %RequestSeal.Digest.Error{reason: :invalid_digest_length}}. Meanings:RequestSeal.Digest.Error.
Examples
iex> alias RequestSeal.StructuredFields.Value
iex> checksum = :crypto.hash(:sha256, "invoice 2041")
iex> RequestSeal.Digest.serialize(%Value{type: :dictionary, value: [{"sha-256", %Value{type: :item, value: {:bytes, checksum}}}]})
{:ok, "sha-256=:" <> Base.encode64(checksum) <> ":"}
iex> RequestSeal.Digest.serialize(%Value{type: :dictionary, value: [{"sha-512", %Value{type: :item, value: {:bytes, checksum}}}]})
{:error, %RequestSeal.Digest.Error{reason: :invalid_digest_length}}
@spec serialize_preferences(RequestSeal.StructuredFields.Value.t()) :: {:ok, binary()} | failure()
Serialize an RFC 9530 preference dictionary with weight validation.
Parameters
value- aRequestSeal.StructuredFields.Valuedictionary whose members are{:integer, weight}items with weights in 0..10.
Returns
{:ok, wire}- canonical field value bytes;""for an empty dictionary (omit the field).{:error, %RequestSeal.StructuredFields.Error{}}-:invalid_value,:schema_type,:revision_type, or:limit. Meanings:RequestSeal.StructuredFields.Error.{:error, %RequestSeal.Digest.Error{reason: :invalid_preference}}. Meanings:RequestSeal.Digest.Error.
Examples
iex> alias RequestSeal.StructuredFields.Value
iex> weight = fn n -> %Value{type: :item, value: {:integer, n}} end
iex> RequestSeal.Digest.serialize_preferences(%Value{type: :dictionary, value: [{"sha-256", weight.(10)}, {"sha-512", weight.(1)}]})
{:ok, "sha-256=10, sha-512=1"}
iex> RequestSeal.Digest.serialize_preferences(%Value{type: :dictionary, value: [{"sha-256", weight.(-1)}]})
{:error, %RequestSeal.Digest.Error{reason: :invalid_preference}}
Hash one binary content chunk, rejecting overflow before cryptographic work.
A chunk that would take the total past the state's :max_bytes rejects before
it is hashed. The input state is not changed; continue with the returned state.
Parameters
state- aRequestSeal.Digeststate frominit/3or a previousupdate/2.chunk- a binary; an empty binary is allowed.
Returns
{:ok, %RequestSeal.Digest{}}- the new state with:bytesincreased.{:error, %RequestSeal.Digest.Error{}}- reasons this function can return::invalid_state,:invalid_chunk, and:limit. Meanings:RequestSeal.Digest.Error.
Examples
iex> {:ok, state} = RequestSeal.Digest.init(:content, ["sha-256"], max_bytes: 12)
iex> {:ok, state} = RequestSeal.Digest.update(state, "cpu=0.42;")
iex> state.bytes
9
iex> RequestSeal.Digest.update(state, "mem=0.91;")
{:error, %RequestSeal.Digest.Error{reason: :limit}}
iex> RequestSeal.Digest.update(state, [?a])
{:error, %RequestSeal.Digest.Error{reason: :invalid_chunk}}