shape/hashing.rs
1use std::hash::Hash;
2use std::hash::Hasher;
3
4use super::ShapeCase;
5use crate::helpers::inner_hasher;
6
7/// Because [`ShapeCase`] uses [`indexmap::IndexMap`] and [`indexmap::IndexSet`]
8/// for its Object, One, and All variants, the default derived Hash trait
9/// implementation does not work. Instead, we hash those cases manually, using a
10/// commutative operation (XOR) to combine the order-insensitive hashes of the
11/// elements.
12///
13/// ## Why plain XOR is safe here
14///
15/// XOR has two known footguns when used as an order-insensitive combiner:
16///
17/// 1. **Self-cancellation (`h ⊕ h = 0`):** if the same element-hash appears
18/// twice in the fold, the two contributions cancel out. Unreachable here:
19/// each combined sequence comes from an `IndexMap`/`IndexSet`, which
20/// deduplicates entries by key/value before iteration, so no element-hash
21/// can appear more than once.
22///
23/// 2. **Cross-cancellation (distinct elements that happen to hash to the same
24/// `u64`):** if two *distinct* elements collide in the inner hasher's
25/// 64-bit output, their contributions XOR-cancel. With a deterministic
26/// inner hasher (the previous behavior) an attacker could precompute such
27/// collisions offline. With this implementation, inner hashers are
28/// constructed via [`crate::helpers::inner_hasher`], which is seeded from
29/// a per-process random `RandomState` — the same protection that
30/// `std::collections::HashMap` relies on for its outer key hashing. Finding
31/// a collision now requires either a birthday-paradox attack with `~2^32`
32/// distinct inputs (negligible at practical scale) or breaking `SipHash`
33/// with a random key, neither of which is feasible to engineer
34/// adversarially.
35///
36/// Within a single process run, equal shapes still hash to equal values (the
37/// random seed is initialized once and reused for every inner hasher). Raw
38/// `u64` hash output differs across process boundaries, which already matches
39/// the behavior any default `HashMap` lookup exhibits.
40impl Hash for ShapeCase {
41 fn hash<H: Hasher>(&self, state: &mut H) {
42 match self {
43 Self::Bool(value) => ("::Bool", value).hash(state),
44 Self::String(value) => ("::String", value).hash(state),
45 Self::Int(value) => ("::Int", value).hash(state),
46 Self::Float => "::Float".hash(state),
47 Self::Null => "::Null".hash(state),
48 Self::Array { prefix, tail } => ("::Array", prefix, tail).hash(state),
49 Self::Object { fields, rest } => {
50 // Plain XOR — `IndexMap` ensures each key is iterated once
51 // (no self-cancellation), and `inner_hasher()` is randomly
52 // seeded (no precomputable cross-cancellation). See the
53 // module-level doc for the full argument.
54 let fields_hash = fields.iter().fold(0, |acc, (key, value)| {
55 let mut fields_hasher = inner_hasher();
56 key.hash(&mut fields_hasher);
57 value.hash(&mut fields_hasher);
58 acc ^ fields_hasher.finish()
59 });
60 ("::Object", fields_hash, rest).hash(state);
61 }
62 Self::One(shapes) => {
63 // Plain XOR — `IndexSet`-backed union, same safety argument
64 // as the `::Object` arm above.
65 let xor_hash = shapes.iter().fold(0, |acc, shape| {
66 let mut shape_hasher = inner_hasher();
67 shape.hash(&mut shape_hasher);
68 acc ^ shape_hasher.finish()
69 });
70 ("::One", xor_hash).hash(state);
71 }
72 Self::All(shapes) => {
73 // Plain XOR — `IndexSet`-backed intersection, same safety
74 // argument as the `::Object` arm above.
75 let xor_hash = shapes.iter().fold(0, |acc, shape| {
76 let mut shape_hasher = inner_hasher();
77 shape.hash(&mut shape_hasher);
78 acc ^ shape_hasher.finish()
79 });
80 ("::All", xor_hash).hash(state);
81 }
82 // Ignore the WeakScope in order to maintain a stable hash
83 // before/after Namespace finalization.
84 Self::Name(name, _weak) => ("::Name", name).hash(state),
85 Self::Unknown => "::Unknown".hash(state),
86 Self::None => "::None".hash(state),
87 }
88 }
89}