Skip to main content

seam_codegen/
rpc_hash.rs

1/* src/cli/codegen/src/rpc_hash.rs */
2
3// RPC endpoint hash map: maps procedure names to short hex hashes for obfuscation.
4
5use std::collections::BTreeMap;
6
7use anyhow::{Result, bail};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct RpcHashMap {
13	pub salt: String,
14	pub batch: String,
15	pub procedures: BTreeMap<String, String>,
16}
17
18/// Generate 16 hex chars (8 random bytes) for use as a hash salt.
19pub fn generate_random_salt() -> String {
20	let bytes: [u8; 8] = rand::random();
21	hex::encode(bytes)
22}
23
24/// Hash a name with a salt, returning `prefix` + exactly `hash_length` hex chars.
25fn hash_name(name: &str, salt: &str, hash_length: usize, prefix: &str) -> String {
26	let mut hasher = Sha256::new();
27	hasher.update(name.as_bytes());
28	hasher.update(salt.as_bytes());
29	let result = hasher.finalize();
30	let bytes_needed = hash_length.div_ceil(2);
31	let hex = hex::encode(&result[..bytes_needed]);
32	format!("{}{}", prefix, &hex[..hash_length])
33}
34
35/// Build an RPC hash map from procedure names and a salt.
36/// When `type_hint` is true, hashes use `rpc-` prefix.
37/// `hash_length` controls the number of hex chars in the hash portion.
38/// Detects collisions and retries with modified salt (up to 100 attempts).
39pub fn generate_rpc_hash_map(
40	names: &[&str],
41	salt: &str,
42	hash_length: usize,
43	type_hint: bool,
44) -> Result<RpcHashMap> {
45	let prefix = if type_hint { "rpc-" } else { "" };
46
47	for attempt in 0..100u32 {
48		let effective_salt = if attempt == 0 { salt.to_string() } else { format!("{salt}{attempt}") };
49
50		let mut procedures = BTreeMap::new();
51		let mut seen = BTreeMap::new();
52		let mut collision = false;
53
54		// Hash _batch first
55		let batch_hash = hash_name("_batch", &effective_salt, hash_length, prefix);
56		seen.insert(batch_hash.clone(), "_batch".to_string());
57
58		for &name in names {
59			let hash = hash_name(name, &effective_salt, hash_length, prefix);
60			if let Some(existing) = seen.get(&hash)
61				&& existing != name
62			{
63				collision = true;
64				break;
65			}
66			seen.insert(hash.clone(), name.to_string());
67			procedures.insert(name.to_string(), hash);
68		}
69
70		if !collision {
71			return Ok(RpcHashMap { salt: effective_salt, batch: batch_hash, procedures });
72		}
73	}
74
75	bail!("failed to generate collision-free RPC hash map after 100 attempts")
76}
77
78#[cfg(test)]
79mod tests {
80	use super::*;
81
82	#[test]
83	fn deterministic_with_same_salt() {
84		let map1 =
85			generate_rpc_hash_map(&["getUser", "getSession"], "abcd1234abcd1234", 12, true).unwrap();
86		let map2 =
87			generate_rpc_hash_map(&["getUser", "getSession"], "abcd1234abcd1234", 12, true).unwrap();
88		assert_eq!(map1.procedures, map2.procedures);
89		assert_eq!(map1.batch, map2.batch);
90	}
91
92	#[test]
93	fn different_salt_different_hashes() {
94		let map1 = generate_rpc_hash_map(&["getUser"], "salt_a_1234567890", 12, true).unwrap();
95		let map2 = generate_rpc_hash_map(&["getUser"], "salt_b_1234567890", 12, true).unwrap();
96		assert_ne!(map1.procedures["getUser"], map2.procedures["getUser"]);
97	}
98
99	#[test]
100	fn no_collision_on_typical_set() {
101		let names: Vec<&str> = vec![
102			"getUser",
103			"getSession",
104			"listPosts",
105			"createPost",
106			"updatePost",
107			"deletePost",
108			"getComments",
109			"addComment",
110		];
111		let map = generate_rpc_hash_map(&names, "test_salt_12345678", 12, true).unwrap();
112		assert_eq!(map.procedures.len(), names.len());
113		// All hashes are unique
114		let hashes: std::collections::HashSet<_> = map.procedures.values().collect();
115		assert_eq!(hashes.len(), names.len());
116	}
117
118	#[test]
119	fn hash_length_type_hint_true() {
120		let map = generate_rpc_hash_map(&["test"], "salt_for_testing_1", 12, true).unwrap();
121		let hash = &map.procedures["test"];
122		// rpc- prefix (4) + 12 hex chars = 16 total
123		assert_eq!(hash.len(), 16);
124		assert!(hash.starts_with("rpc-"));
125		assert!(hash[4..].chars().all(|c| c.is_ascii_hexdigit()));
126		assert_eq!(map.batch.len(), 16);
127		assert!(map.batch.starts_with("rpc-"));
128	}
129
130	#[test]
131	fn hash_length_type_hint_false() {
132		let map = generate_rpc_hash_map(&["test"], "salt_for_testing_1", 12, false).unwrap();
133		let hash = &map.procedures["test"];
134		// bare 12 hex chars, no prefix
135		assert_eq!(hash.len(), 12);
136		assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
137		assert!(!hash.starts_with("rpc-"));
138		assert_eq!(map.batch.len(), 12);
139	}
140
141	#[test]
142	fn hash_length_custom() {
143		// 8 hex chars
144		let map = generate_rpc_hash_map(&["test"], "salt_for_testing_1", 8, true).unwrap();
145		let hash = &map.procedures["test"];
146		assert_eq!(hash.len(), 12); // rpc- (4) + 8 hex
147		assert!(hash.starts_with("rpc-"));
148		assert!(hash[4..].chars().all(|c| c.is_ascii_hexdigit()));
149
150		// 20 hex chars
151		let map = generate_rpc_hash_map(&["test"], "salt_for_testing_1", 20, false).unwrap();
152		let hash = &map.procedures["test"];
153		assert_eq!(hash.len(), 20);
154		assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
155
156		// Odd length (7 hex chars)
157		let map = generate_rpc_hash_map(&["test"], "salt_for_testing_1", 7, false).unwrap();
158		let hash = &map.procedures["test"];
159		assert_eq!(hash.len(), 7);
160		assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
161	}
162
163	#[test]
164	fn serialization_roundtrip() {
165		let map = generate_rpc_hash_map(&["a", "b"], "roundtrip_salt_00", 12, true).unwrap();
166		let json = serde_json::to_string(&map).unwrap();
167		let restored: RpcHashMap = serde_json::from_str(&json).unwrap();
168		assert_eq!(map.salt, restored.salt);
169		assert_eq!(map.batch, restored.batch);
170		assert_eq!(map.procedures, restored.procedures);
171	}
172
173	#[test]
174	fn random_salt_is_16_hex_chars() {
175		let salt = generate_random_salt();
176		assert_eq!(salt.len(), 16);
177		assert!(salt.chars().all(|c| c.is_ascii_hexdigit()));
178	}
179
180	#[test]
181	fn empty_procedures() {
182		let map = generate_rpc_hash_map(&[], "empty_salt_123456", 12, true).unwrap();
183		assert!(map.procedures.is_empty());
184		assert!(!map.batch.is_empty());
185	}
186}