Skip to main content

pofile/
message_id.rs

1//! Stable message ID generation.
2
3use std::collections::BTreeMap;
4
5const ID_LENGTH: usize = 8;
6const BASE64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
7const K: [u32; 64] = [
8    0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
9    0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
10    0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
11    0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
12    0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
13    0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
14    0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
15    0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
16];
17
18/// Message input used for batch message ID generation.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct MessageIdInput {
21    /// Source message text.
22    pub message: String,
23    /// Optional message context.
24    pub context: Option<String>,
25}
26
27impl MessageIdInput {
28    /// Create a new message input.
29    #[must_use]
30    pub fn new(message: impl Into<String>, context: Option<impl Into<String>>) -> Self {
31        Self {
32            message: message.into(),
33            context: context.map(Into::into),
34        }
35    }
36}
37
38/// Generate a stable 8-character Base64URL message ID.
39#[must_use]
40pub fn generate_message_id(message: &str, context: Option<&str>) -> String {
41    let mut input = String::new();
42    if let Some(context) = context {
43        input.push_str(context);
44    }
45    input.push_str(message);
46
47    let digest = sha256(input.as_bytes());
48    bytes_to_base64url(&digest)
49}
50
51/// Generate message IDs for multiple messages.
52#[must_use]
53pub fn generate_message_ids(inputs: &[MessageIdInput]) -> BTreeMap<String, String> {
54    let mut results = BTreeMap::new();
55
56    for input in inputs {
57        let id = generate_message_id(&input.message, input.context.as_deref());
58        let key = match &input.context {
59            Some(context) => format!("{}\u{0004}{context}", input.message),
60            None => input.message.clone(),
61        };
62        results.insert(key, id);
63    }
64
65    results
66}
67
68fn bytes_to_base64url(bytes: &[u8]) -> String {
69    let mut result = String::with_capacity(ID_LENGTH);
70    let mut bits = 0u32;
71    let mut value = 0u32;
72
73    for byte in bytes {
74        if result.len() == ID_LENGTH {
75            break;
76        }
77
78        value = (value << 8) | u32::from(*byte);
79        bits += 8;
80
81        while bits >= 6 && result.len() < ID_LENGTH {
82            bits -= 6;
83            let index = ((value >> bits) & 0x3f) as usize;
84            result.push(char::from(BASE64URL[index]));
85        }
86    }
87
88    result
89}
90
91fn sha256(bytes: &[u8]) -> [u8; 32] {
92    let mut padded = bytes.to_vec();
93    let bit_length = (padded.len() as u64) * 8;
94    padded.push(0x80);
95    while (padded.len() % 64) != 56 {
96        padded.push(0);
97    }
98    padded.extend_from_slice(&bit_length.to_be_bytes());
99
100    let mut state = [
101        0x6a09e667u32,
102        0xbb67ae85,
103        0x3c6ef372,
104        0xa54ff53a,
105        0x510e527f,
106        0x9b05688c,
107        0x1f83d9ab,
108        0x5be0cd19,
109    ];
110
111    for chunk in padded.chunks_exact(64) {
112        let mut w = [0u32; 64];
113        for (index, word) in chunk.chunks_exact(4).enumerate().take(16) {
114            w[index] = u32::from_be_bytes([word[0], word[1], word[2], word[3]]);
115        }
116
117        for index in 16..64 {
118            let s0 = w[index - 15].rotate_right(7)
119                ^ w[index - 15].rotate_right(18)
120                ^ (w[index - 15] >> 3);
121            let s1 = w[index - 2].rotate_right(17)
122                ^ w[index - 2].rotate_right(19)
123                ^ (w[index - 2] >> 10);
124            w[index] = w[index - 16]
125                .wrapping_add(s0)
126                .wrapping_add(w[index - 7])
127                .wrapping_add(s1);
128        }
129
130        let mut a = state[0];
131        let mut b = state[1];
132        let mut c = state[2];
133        let mut d = state[3];
134        let mut e = state[4];
135        let mut f = state[5];
136        let mut g = state[6];
137        let mut h = state[7];
138
139        for index in 0..64 {
140            let sum1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
141            let choice = (e & f) ^ ((!e) & g);
142            let temp1 = h
143                .wrapping_add(sum1)
144                .wrapping_add(choice)
145                .wrapping_add(K[index])
146                .wrapping_add(w[index]);
147            let sum0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
148            let majority = (a & b) ^ (a & c) ^ (b & c);
149            let temp2 = sum0.wrapping_add(majority);
150
151            h = g;
152            g = f;
153            f = e;
154            e = d.wrapping_add(temp1);
155            d = c;
156            c = b;
157            b = a;
158            a = temp1.wrapping_add(temp2);
159        }
160
161        state[0] = state[0].wrapping_add(a);
162        state[1] = state[1].wrapping_add(b);
163        state[2] = state[2].wrapping_add(c);
164        state[3] = state[3].wrapping_add(d);
165        state[4] = state[4].wrapping_add(e);
166        state[5] = state[5].wrapping_add(f);
167        state[6] = state[6].wrapping_add(g);
168        state[7] = state[7].wrapping_add(h);
169    }
170
171    let mut output = [0u8; 32];
172    for (index, word) in state.iter().enumerate() {
173        output[index * 4..index * 4 + 4].copy_from_slice(&word.to_be_bytes());
174    }
175    output
176}
177
178#[cfg(test)]
179mod tests {
180    use std::collections::BTreeMap;
181
182    use super::{generate_message_id, generate_message_ids, MessageIdInput};
183
184    #[test]
185    fn generate_message_id_matches_current_node_implementation() {
186        assert_eq!(generate_message_id("Hello", None), "GF-NsyJx");
187        assert_eq!(generate_message_id("Hello World", None), "pZGm1Av0");
188        assert_eq!(generate_message_id("Open", Some("menu.file")), "QWh_hL4_");
189        assert_eq!(generate_message_id("Привет мир 🌍", None), "Xp6zAZTu");
190    }
191
192    #[test]
193    fn generate_message_ids_batches_results() {
194        let ids = generate_message_ids(&[
195            MessageIdInput::new("Hello", None::<String>),
196            MessageIdInput::new("Open", Some("menu.file")),
197        ]);
198
199        assert_eq!(
200            ids,
201            BTreeMap::from([
202                (String::from("Hello"), String::from("GF-NsyJx")),
203                (
204                    String::from("Open\u{0004}menu.file"),
205                    String::from("QWh_hL4_"),
206                ),
207            ])
208        );
209    }
210}