1use sha2::{Sha256, Digest};
5use blake3;
6use serde::{Deserialize, Serialize};
7use indexmap::IndexMap;
8use unicode_normalization::UnicodeNormalization;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct StableHashConfig {
13 pub recipe: String,
15
16 pub algorithm: HashAlgorithm,
18
19 pub use_cache: bool,
21
22 pub salt: Option<String>,
24}
25
26impl Default for StableHashConfig {
27 fn default() -> Self {
28 Self {
29 recipe: "v1".to_string(),
30 algorithm: HashAlgorithm::Blake3,
31 use_cache: true,
32 salt: None,
33 }
34 }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39pub enum HashAlgorithm {
40 Sha256,
42 Blake3,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct HashRecipe {
49 pub fields: Vec<String>,
51
52 pub normalize: NormalizeOptions,
54
55 pub salt: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct NormalizeOptions {
62 pub unicode: UnicodeForm,
64
65 pub trim: bool,
67
68 pub case: CaseNormalization,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73pub enum UnicodeForm {
74 NFC,
75 NFD,
76 NFKC,
77 NFKD,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81pub enum CaseNormalization {
82 AsIs,
83 Lower,
84 Upper,
85}
86
87pub struct StableHashGenerator {
89 config: StableHashConfig,
90 recipes: IndexMap<String, HashRecipe>,
91 cache: IndexMap<String, String>,
92}
93
94impl StableHashGenerator {
95 pub fn new(config: StableHashConfig) -> Self {
97 Self {
98 config,
99 recipes: Self::load_recipes(),
100 cache: IndexMap::new(),
101 }
102 }
103
104 pub fn generate_release_id(
106 &mut self,
107 upc: &str,
108 release_type: &str,
109 track_isrcs: &[String],
110 territory_set: &[String],
111 ) -> Result<String, super::error::BuildError> {
112 let materials = ReleaseHashMaterials {
113 upc: upc.to_string(),
114 release_type: release_type.to_string(),
115 track_isrcs: track_isrcs.to_vec(),
116 territory_set: territory_set.to_vec(),
117 };
118
119 self.generate("Release", &materials)
120 }
121
122 pub fn generate_resource_id(
124 &mut self,
125 isrc: &str,
126 duration: u32,
127 file_hash: Option<&str>,
128 ) -> Result<String, super::error::BuildError> {
129 let materials = ResourceHashMaterials {
130 isrc: isrc.to_string(),
131 duration,
132 file_hash: file_hash.map(|s| s.to_string()),
133 };
134
135 self.generate("Resource", &materials)
136 }
137
138 pub fn generate_party_id(
140 &mut self,
141 name: &str,
142 role: &str,
143 identifiers: &[String],
144 ) -> Result<String, super::error::BuildError> {
145 let materials = PartyHashMaterials {
146 name: name.to_string(),
147 role: role.to_string(),
148 identifiers: identifiers.to_vec(),
149 };
150
151 self.generate("Party", &materials)
152 }
153
154 fn generate<T: Serialize>(
156 &mut self,
157 entity_type: &str,
158 materials: &T,
159 ) -> Result<String, super::error::BuildError> {
160 let cache_key = format!("{}:{}", entity_type,
162 serde_json::to_string(materials)?);
163
164 if self.config.use_cache {
166 if let Some(cached) = self.cache.get(&cache_key) {
167 return Ok(cached.clone());
168 }
169 }
170
171 let recipe = self.recipes.get(&format!("{}.{}", entity_type, self.config.recipe))
173 .ok_or_else(|| super::error::BuildError::InvalidFormat {
174 field: "recipe".to_string(),
175 message: format!("No recipe for {}.{}", entity_type, self.config.recipe),
176 })?;
177
178 let normalized = self.normalize_materials(materials, recipe)?;
180
181 let id = match self.config.algorithm {
183 HashAlgorithm::Sha256 => self.hash_sha256(&normalized, &recipe.salt),
184 HashAlgorithm::Blake3 => self.hash_blake3(&normalized, &recipe.salt),
185 };
186
187 if self.config.use_cache {
189 self.cache.insert(cache_key, id.clone());
190 }
191
192 Ok(id)
193 }
194
195 fn normalize_materials<T: Serialize>(
196 &self,
197 materials: &T,
198 recipe: &HashRecipe,
199 ) -> Result<String, super::error::BuildError> {
200 let json = serde_json::to_value(materials)?;
201 let mut parts = Vec::new();
202
203 for field in &recipe.fields {
204 if let Some(value) = json.get(field) {
205 let normalized = self.normalize_value(value, &recipe.normalize)?;
206 parts.push(normalized);
207 }
208 }
209
210 Ok(parts.join("|"))
211 }
212
213 fn normalize_value(
214 &self,
215 value: &serde_json::Value,
216 options: &NormalizeOptions,
217 ) -> Result<String, super::error::BuildError> {
218 let text = match value {
219 serde_json::Value::String(s) => s.clone(),
220 serde_json::Value::Array(arr) => {
221 let strings: Vec<String> = arr.iter()
222 .map(|v| self.normalize_value(v, options))
223 .collect::<Result<Vec<_>, _>>()?;
224 strings.join(",")
225 },
226 _ => serde_json::to_string(value)?,
227 };
228
229 let mut normalized = text;
231
232 normalized = match options.unicode {
234 UnicodeForm::NFC => normalized.nfc().collect(),
235 UnicodeForm::NFD => normalized.nfd().collect(),
236 UnicodeForm::NFKC => normalized.nfkc().collect(),
237 UnicodeForm::NFKD => normalized.nfkd().collect(),
238 };
239
240 if options.trim {
242 normalized = normalized.trim().to_string();
243 }
244
245 normalized = match options.case {
247 CaseNormalization::AsIs => normalized,
248 CaseNormalization::Lower => normalized.to_lowercase(),
249 CaseNormalization::Upper => normalized.to_uppercase(),
250 };
251
252 Ok(normalized)
253 }
254
255 fn hash_sha256(&self, input: &str, salt: &str) -> String {
256 let mut hasher = Sha256::new();
257 hasher.update(salt.as_bytes());
258 hasher.update(input.as_bytes());
259 if let Some(global_salt) = &self.config.salt {
260 hasher.update(global_salt.as_bytes());
261 }
262 let result = hasher.finalize();
263 format!("SHA256:{:x}", result)
264 }
265
266 fn hash_blake3(&self, input: &str, salt: &str) -> String {
267 let mut hasher = blake3::Hasher::new();
268 hasher.update(salt.as_bytes());
269 hasher.update(input.as_bytes());
270 if let Some(global_salt) = &self.config.salt {
271 hasher.update(global_salt.as_bytes());
272 }
273 let hash = hasher.finalize();
274 format!("B3:{}", hash.to_hex())
275 }
276
277 fn load_recipes() -> IndexMap<String, HashRecipe> {
278 let mut recipes = IndexMap::new();
279
280 recipes.insert("Release.v1".to_string(), HashRecipe {
282 fields: vec![
283 "upc".to_string(),
284 "release_type".to_string(),
285 "track_isrcs".to_string(),
286 "territory_set".to_string(),
287 ],
288 normalize: NormalizeOptions {
289 unicode: UnicodeForm::NFC,
290 trim: true,
291 case: CaseNormalization::AsIs,
292 },
293 salt: "REL@1".to_string(),
294 });
295
296 recipes.insert("Resource.v1".to_string(), HashRecipe {
298 fields: vec![
299 "isrc".to_string(),
300 "duration".to_string(),
301 "file_hash".to_string(),
302 ],
303 normalize: NormalizeOptions {
304 unicode: UnicodeForm::NFC,
305 trim: true,
306 case: CaseNormalization::AsIs,
307 },
308 salt: "RES@1".to_string(),
309 });
310
311 recipes.insert("Party.v1".to_string(), HashRecipe {
313 fields: vec![
314 "name".to_string(),
315 "role".to_string(),
316 "identifiers".to_string(),
317 ],
318 normalize: NormalizeOptions {
319 unicode: UnicodeForm::NFC,
320 trim: true,
321 case: CaseNormalization::Lower,
322 },
323 salt: "PTY@1".to_string(),
324 });
325
326 recipes
327 }
328}
329
330#[derive(Debug, Serialize)]
332struct ReleaseHashMaterials {
333 upc: String,
334 release_type: String,
335 track_isrcs: Vec<String>,
336 territory_set: Vec<String>,
337}
338
339#[derive(Debug, Serialize)]
340struct ResourceHashMaterials {
341 isrc: String,
342 duration: u32,
343 file_hash: Option<String>,
344}
345
346#[derive(Debug, Serialize)]
347struct PartyHashMaterials {
348 name: String,
349 role: String,
350 identifiers: Vec<String>,
351}