Expand description
§object-hash
A stable hash of a structured value.
Given a map, an array, a string, a number, nested in any combination, this produces a short digest that is the same every time for values that are equal — across runs, across processes, and across machines.
use object_hash::{Value, hash};
let one = Value::Map(vec![
("name".into(), Value::String("cipansor".into())),
("year".into(), Value::Number(2026.0)),
]);
// The same entries, inserted the other way round.
let two = Value::Map(vec![
("year".into(), Value::Number(2026.0)),
("name".into(), Value::String("cipansor".into())),
]);
assert_eq!(hash(&one), hash(&two));§Why Hash is not enough
std::hash::Hash looks like the right tool and is not. The default hasher,
RandomState, is seeded per process, so the same value hashes differently every
time the program starts. That is exactly right for a HashMap — the seed is what
stops an attacker choosing collisions — and exactly wrong for anything that has
to be written down or compared across processes. The other half of the problem is
that a HashMap iterates in an unspecified order, so even with a fixed hasher two
equal maps can hash differently.
So the value is normalised first, into a canonical form that depends only on what the value is, and then those bytes are hashed. This crate does both.
§What it is for
- Cache keys that hit the same entry for the same request.
- ETags that change when the content changes and not when a field is reordered.
- Idempotency keys that recognise a repeated request as the same action.
- Change detection and deduplication by comparing two digests.
§The equality rules
| Value | Rule |
|---|---|
Map | Written in key order; insertion order does not matter; a repeated key keeps the last value |
Object | A plain object, encoded differently from a Map; keys are written unquoted, in key order, and a repeated key keeps the last value |
Array | Order matters |
Set | Order does not matter; repeated scalars are dropped, repeated arrays are not (a JavaScript Set compares arrays by identity) |
Number | An integer and an equal float are the same number; -0.0 is 0; NaN, Infinity and -Infinity have names; the text is JavaScript’s, so 1e21 is 1e+21 and 1e-7 is 1e-7 |
String | Carries its length in UTF-16 code units, so an emoji counts as two |
Null | Its own value, distinct from the empty string |
The length prefix is what stops {"ab": 1} and {"a": "b1"} from writing the
same bytes.
§Map and Object are not interchangeable
The reference has both a Map and a plain object and encodes them differently,
so the same entries give two different digests:
use object_hash::{Value, hash};
let object = Value::Object(vec![("a".into(), Value::number(1))]);
let map = Value::Map(vec![("a".into(), Value::number(1))]);
assert_ne!(hash(&object), hash(&map));Pick the one the other side of the conversation is hashing. If a Node service
passes a plain object, hash Value::Object; if it passes a Map, hash
Value::Map.
§Options
Options mirrors the reference’s options, and Options::default() is what it
does when nothing is passed. Pass them to canonical_with, hash_with_options
or hash_with_options_and.
| Field | Default | Effect |
|---|---|---|
exclude_values | false | Write only an object’s keys, ignoring the values. This is the reference’s keys helper |
unordered_arrays | false | Sort an array’s entries, so order stops mattering |
unordered_sets | true | Sort a set’s entries. Turning it off keeps the insertion order |
unordered_objects | true | Sort an object’s keys |
use object_hash::{Options, Value, canonical_with};
let keys = Value::Object(vec![
("a".into(), Value::number(1)),
("b".into(), Value::number(2)),
]);
// Only the keys are written.
assert_eq!(canonical_with(&keys, &Options::keys()), "object:2:string:1:a:,string:1:b:,");§Compatibility with the npm package
The canonical form is the one the object-hash npm package produces, byte for
byte, and the digest is SHA-1 in lowercase hexadecimal, which is its default. A
digest computed here equals one computed there for the same value, so a Rust
service and a Node service can agree on a cache key without either knowing about
the other. That is why a map is written as an array of pairs, and why an integral
float has no decimal point: the reference writes them that way.
The one deliberate difference is the reference’s respectType, which is on by
default there. It makes every plain object carry a preamble describing Node’s
prototype chain — prototype, __proto__ and constructor — which is a fact
about the JavaScript runtime rather than about the value, and can even depend on
where the object sits in the traversal. A Rust caller has no prototype to respect.
This crate always encodes the respectType: false form, which is why
Value::Object and Value::Map are both here and both reproducible: hash a
plain object there with {respectType: false} to match Value::Object, or pass
it as a Map there to match Value::Map.
§Choosing the digest
hash uses SHA-1, the reference’s default. hash_with takes an Algorithm
when a digest has to match a system that already speaks another one:
Algorithm | Characters | When |
|---|---|---|
Sha1 | 40 | The default, and what the npm package produces |
Sha256 | 64 | A digest that is not SHA-1 |
Sha224 | 56 | Matching a system that already uses SHA-224 |
Sha512 | 128 | Matching a system that already uses SHA-512 |
Sha384 | 96 | Matching a system that already uses SHA-384 |
Md5 | 32 | Matching an existing MD5 in the field |
Passthrough | - | The canonical bytes themselves, no digest |
The canonical bytes are the same whatever the algorithm, so the six digests agree on which values are equal and differ only in the text they produce. SHA-256, SHA-224, SHA-512, SHA-384 and MD5 are written out here like SHA-1, so the crate still has no dependencies. SHA-224 is SHA-256 and SHA-384 is SHA-512, each with a different initial state and a truncated output, so each pair shares its round code.
Passthrough is the reference’s seventh algorithm and the odd one out: it is not
a digest at all but the byte stream the digests are taken over, so
hash_with(value, Algorithm::Passthrough) returns the same string canonical
does. It is here because the reference names it, and because seeing those bytes
without a second function is occasionally what a caller wants.
hash_with_encoding takes an Encoding as well, mirroring the reference’s
encoding option:
Encoding | When |
|---|---|
Hex | The default, and what the npm package produces |
Base64 | A shorter key, standard base64 with padding |
Raw | The digest bytes as one character each |
raw_hash_with and raw_hash_with_options return the digest as Vec<u8>
directly, which is the reference’s encoding: 'buffer' without a text form in
between.
§What it is not
SHA-1 and MD5 are not used here as security primitives. A digest names a value;
it does not protect it, and it is not a signature. canonical is exported, so if
a different digest is wanted, hash the canonical bytes with any other function.
§How it is checked
Against the object-hash npm package, 84 curated cases: the reference’s own
canonical byte stream and its default digest for strings (including emoji and
CJK), nested maps, plain objects, arrays and sets, the deduplication rules, and
the sorting of unordered collections. Numbers get a file of their own, 18 cases,
because Rust and JavaScript disagree about where to put the decimal point: Rust
prints 1e21 as twenty-two digits where JavaScript prints 1e+21, and 1e-7 as
0.0000001 where JavaScript prints 1e-7. The text is generated by the
ECMAScript rules rather than by Rust’s Display, and the boundaries are checked
in both directions. All six digests are checked: the curated cases carry the
reference’s SHA-1, SHA-256, SHA-224, SHA-512, SHA-384 and MD5 over its own
canonical bytes, along with the base64 of the SHA-1 digest, and each algorithm is
checked against its published vectors, including the long ones. SHA-384 is the
one that caught a transcription error: a single wrong digit in its initial state
still produced a plausible-looking 96-character digest, so only a digest taken
from the reference (or the FIPS vector) rather than written by hand could tell it
apart. SHA-224 is pinned the same way, against both the reference and the FIPS
vectors.
There is also a corpus of 4,000 values built at random - nesting arrays, sets,
maps and plain objects, numbers at both ends of the range, strings holding emoji,
CJK and U+FFFF, repeated keys and entries - with the reference’s canonical form
and digest for each. It is what found the number formatting and the UTF-16
ordering; the curated cases had missed both. A second corpus of 2,000 plain
objects pins the object: encoding and the excludeValues and unorderedArrays
options. The generator that built both is in scripts/fuzz/object-hash/, so the
corpora can be rebuilt and extended.
Passthrough is checked against the same canonical column every other algorithm
is taken over, so the reference’s own bytes pin it in both the curated file and
the random corpus rather than only in a hand-written example.
The plain-object rows are hashed with respectType: false, because the
reference’s default prepends a Node prototype preamble that no other language can
reproduce. That is the one option this crate does not mirror; everything else it
does.
One ordering rule is worth stating because it is not obvious: entries of a set or map are ordered by UTF-16 code unit, as JavaScript orders strings, not by UTF-8 byte. The two disagree above the basic plane, where an emoji sorts before U+FFFF.
Part of the crateworks workspace. Licensed under the Apache License, Version 2.0, copyright Yayasan Pesantren Cipansor and contributors.
Structs§
- Options
- The choices that change the canonical bytes, mirroring the reference’s options.
Enums§
- Algorithm
- The digest function used to turn the canonical form into a digest.
- Encoding
- How the digest is written out.
- Value
- A value that can be hashed.
Functions§
- canonical
- The canonical form of a value, as text, with the default options.
- canonical_
with - The canonical form of a value, as text, with chosen options.
- hash
- The digest of a value, as lowercase hexadecimal SHA-1.
- hash_
with - The digest of a value with a chosen algorithm, as lowercase hexadecimal.
- hash_
with_ encoding - The digest of a value with chosen options, algorithm and encoding.
- hash_
with_ options - The digest of a value with chosen options, as lowercase hexadecimal SHA-1.
- hash_
with_ options_ and - The digest of a value with chosen options and algorithm.
- hash_
with_ options_ encoding - The digest of a value with every choice named.
- raw_
hash_ with - The raw digest bytes of a value.
- raw_
hash_ with_ options - The raw digest bytes of a value with chosen options.