Skip to main content

io_pimdir/
hash.rs

1//! The content hash naming a body in the object store (spec §5).
2//!
3//! An object's name is its hash, so every process touching one store must
4//! compute the same value: a disagreement writes blobs no other reader
5//! finds, and it fails silently, as a dedup that never dedups. The store
6//! therefore records its algorithm in `store_meta.hash_algo` (spec §4.3)
7//! and hands the digest out, rather than leaving each consumer to pick.
8//!
9//! The encoding is part of the contract: lowercase base32 (RFC 4648, no
10//! padding), because the hash is also a path component and a
11//! single-case, filesystem-safe alphabet is what keeps that path valid
12//! everywhere. Hex would work on Linux and collide on a
13//! case-insensitive filesystem.
14
15use alloc::{boxed::Box, string::String, vec::Vec};
16
17use io_replica::object::ReplicaHash;
18use sha2::{Digest, Sha256};
19
20/// The hash a store names its objects by, as `store_meta.hash_algo`
21/// records it.
22#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
23pub enum PimdirHashAlgo {
24    /// BLAKE3, the whole 256-bit digest, which the spec recommends.
25    #[default]
26    Blake3,
27    /// SHA-256 truncated to its first 128 bits, for a consumer whose
28    /// platform ships SHA-256 and would otherwise bundle a BLAKE3
29    /// implementation (the Android app takes this one).
30    ///
31    /// Content addressing needs collision resistance, not signature
32    /// strength, and a 26-character name keeps the blob paths short.
33    Sha256_128,
34}
35
36impl PimdirHashAlgo {
37    /// The spelling `store_meta.hash_algo` carries.
38    pub fn as_str(&self) -> &'static str {
39        match self {
40            Self::Blake3 => "blake3",
41            Self::Sha256_128 => "sha256-128",
42        }
43    }
44
45    /// The algorithm a stored spelling names, or `None` for one this
46    /// crate does not implement, which a caller reports rather than
47    /// guessing around.
48    pub fn parse(algo: &str) -> Option<Self> {
49        match algo {
50            "blake3" => Some(Self::Blake3),
51            "sha256-128" => Some(Self::Sha256_128),
52            _ => None,
53        }
54    }
55
56    /// The content hash of a whole body.
57    pub fn hash(&self, bytes: &[u8]) -> ReplicaHash {
58        let mut hasher = self.hasher();
59        hasher.update(bytes);
60        hasher.finish()
61    }
62
63    /// An incremental hasher, for a body streamed into the blob store
64    /// rather than held whole in memory (spec §14's byteless
65    /// `StoreObject`).
66    pub fn hasher(&self) -> PimdirHasher {
67        match self {
68            Self::Blake3 => PimdirHasher::Blake3(Box::new(blake3::Hasher::new())),
69            Self::Sha256_128 => PimdirHasher::Sha256_128(Sha256::new()),
70        }
71    }
72}
73
74/// An incremental hasher over a body's bytes (see
75/// [`hasher`](PimdirHashAlgo::hasher)).
76pub enum PimdirHasher {
77    /// A BLAKE3 digest in progress, boxed: its state is nearly two
78    /// kilobytes, which would otherwise size every hasher this enum
79    /// hands out.
80    Blake3(Box<blake3::Hasher>),
81    /// A SHA-256 digest in progress, truncated when it finishes.
82    Sha256_128(Sha256),
83}
84
85impl PimdirHasher {
86    /// Feeds the next bytes of the body.
87    pub fn update(&mut self, bytes: &[u8]) {
88        match self {
89            Self::Blake3(hasher) => {
90                hasher.update(bytes);
91            }
92            Self::Sha256_128(hasher) => hasher.update(bytes),
93        }
94    }
95
96    /// The finished hash, as the object store names it.
97    pub fn finish(self) -> ReplicaHash {
98        let digest: Vec<u8> = match self {
99            Self::Blake3(hasher) => hasher.finalize().as_bytes().to_vec(),
100            Self::Sha256_128(hasher) => hasher.finalize()[..16].to_vec(),
101        };
102
103        ReplicaHash(base32(&digest))
104    }
105}
106
107/// Lowercase base32 (RFC 4648, no padding), the encoding spec §5 fixes
108/// for an object name.
109///
110/// A digest length is rarely a multiple of five bits, so the last
111/// character carries the leftover bits padded with zeroes, which is what
112/// RFC 4648 prescribes once its padding characters are dropped.
113fn base32(digest: &[u8]) -> String {
114    const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
115
116    let mut name = String::with_capacity(digest.len().div_ceil(5) * 8);
117    let mut buffer: u16 = 0;
118    let mut bits = 0;
119
120    for byte in digest {
121        buffer = (buffer << 8) | u16::from(*byte);
122        bits += 8;
123        while bits >= 5 {
124            bits -= 5;
125            name.push(ALPHABET[usize::from((buffer >> bits) & 0x1f)] as char);
126        }
127    }
128    if bits > 0 {
129        name.push(ALPHABET[usize::from((buffer << (5 - bits)) & 0x1f)] as char);
130    }
131
132    name
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn sha256_128_matches_the_shape_every_implementation_must_agree_on() {
141        // NOTE: pinned against the Android app's PimdirHash, which
142        // computes the same name in Java. A disagreement here does not
143        // fail loudly, it writes blobs the other never finds.
144        let hash = PimdirHashAlgo::Sha256_128.hash(b"pimdir");
145        assert_eq!(hash.0.len(), 26);
146        assert!(hash.0.chars().all(|c| ALPHABET_CHARS.contains(c)));
147    }
148
149    const ALPHABET_CHARS: &str = "abcdefghijklmnopqrstuvwxyz234567";
150
151    #[test]
152    fn base32_encodes_rfc_4648_vectors_lowercased() {
153        // RFC 4648 §10, lowercased and unpadded
154        assert_eq!(base32(b"f"), "my");
155        assert_eq!(base32(b"fo"), "mzxq");
156        assert_eq!(base32(b"foo"), "mzxw6");
157        assert_eq!(base32(b"foob"), "mzxw6yq");
158        assert_eq!(base32(b"fooba"), "mzxw6ytb");
159        assert_eq!(base32(b"foobar"), "mzxw6ytboi");
160    }
161
162    #[test]
163    fn a_streamed_body_hashes_like_a_whole_one() {
164        for algo in [PimdirHashAlgo::Blake3, PimdirHashAlgo::Sha256_128] {
165            let mut hasher = algo.hasher();
166            hasher.update(b"BEGIN:VCARD\r\n");
167            hasher.update(b"UID:x\r\nEND:VCARD\r\n");
168            assert_eq!(
169                hasher.finish(),
170                algo.hash(b"BEGIN:VCARD\r\nUID:x\r\nEND:VCARD\r\n")
171            );
172        }
173    }
174
175    #[test]
176    fn the_algorithms_round_trip_through_their_stored_spelling() {
177        for algo in [PimdirHashAlgo::Blake3, PimdirHashAlgo::Sha256_128] {
178            assert_eq!(PimdirHashAlgo::parse(algo.as_str()), Some(algo));
179        }
180        assert_eq!(PimdirHashAlgo::parse("md5"), None);
181        assert_eq!(PimdirHashAlgo::default(), PimdirHashAlgo::Blake3);
182    }
183}