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 ;
let one = Map;
// The same entries, inserted the other way round.
let two = Map;
assert_eq!;
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 |
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.
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 JavaScript objects. An object there has a
prototype and a constructor, and the reference mixes those into the hash — so
{} in JavaScript hashes differently from an empty map here. A Rust caller has
no prototype to respect, and mixing one in would make the digest depend on the
library rather than on the value. Value::Map is the entries and nothing else.
What it is not
SHA-1 is not used here as a security primitive. The 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, over 74 cases: the reference's own
canonical byte stream and its default digest for strings (including emoji and
CJK), nested maps, 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. SHA-1 is checked against the FIPS 180-1 vectors, including the
one-million-character one.
There is also a corpus of 4,000 values built at random - nesting arrays, sets and
maps, 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. The generator that built it is in
scripts/fuzz/object-hash/, so the corpus can be rebuilt and extended.
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.