ddex_builder/
id_generator.rs

1// packages/ddex-builder/src/id_generator.rs
2//! Stable hash-based ID generation for deterministic DDEX messages
3
4use sha2::{Sha256, Digest};
5use blake3;
6use serde::{Deserialize, Serialize};
7use indexmap::IndexMap;
8use unicode_normalization::UnicodeNormalization;
9
10/// Stable hash configuration
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct StableHashConfig {
13    /// Recipe version to use
14    pub recipe: String,
15    
16    /// Hash algorithm
17    pub algorithm: HashAlgorithm,
18    
19    /// Whether to cache generated IDs
20    pub use_cache: bool,
21    
22    /// Salt for hash generation
23    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/// Hash algorithm for stable ID generation
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39pub enum HashAlgorithm {
40    /// SHA-256
41    Sha256,
42    /// Blake3 (faster, more secure)
43    Blake3,
44}
45
46/// Recipe for stable hash generation
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct HashRecipe {
49    /// Fields to include in hash
50    pub fields: Vec<String>,
51    
52    /// Normalization options
53    pub normalize: NormalizeOptions,
54    
55    /// Salt for this entity type
56    pub salt: String,
57}
58
59/// Normalization options for stable hashing
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct NormalizeOptions {
62    /// Unicode normalization form
63    pub unicode: UnicodeForm,
64    
65    /// Whether to trim whitespace
66    pub trim: bool,
67    
68    /// Case normalization
69    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
87/// Stable hash ID generator
88pub struct StableHashGenerator {
89    config: StableHashConfig,
90    recipes: IndexMap<String, HashRecipe>,
91    cache: IndexMap<String, String>,
92}
93
94impl StableHashGenerator {
95    /// Create new generator with config
96    pub fn new(config: StableHashConfig) -> Self {
97        Self {
98            config,
99            recipes: Self::load_recipes(),
100            cache: IndexMap::new(),
101        }
102    }
103    
104    /// Generate stable ID for a release
105    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    /// Generate stable ID for a resource
123    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    /// Generate stable ID for a party
139    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    /// Generic stable ID generation
155    fn generate<T: Serialize>(
156        &mut self,
157        entity_type: &str,
158        materials: &T,
159    ) -> Result<String, super::error::BuildError> {
160        // Create cache key
161        let cache_key = format!("{}:{}", entity_type, 
162            serde_json::to_string(materials)?);
163        
164        // Check cache
165        if self.config.use_cache {
166            if let Some(cached) = self.cache.get(&cache_key) {
167                return Ok(cached.clone());
168            }
169        }
170        
171        // Get recipe
172        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        // Normalize and concatenate fields
179        let normalized = self.normalize_materials(materials, recipe)?;
180        
181        // Generate hash
182        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        // Cache result
188        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        // Apply normalization
230        let mut normalized = text;
231        
232        // Unicode normalization
233        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        // Trim
241        if options.trim {
242            normalized = normalized.trim().to_string();
243        }
244        
245        // Case normalization
246        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        // Release v1 recipe
281        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        // Resource v1 recipe
297        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        // Party v1 recipe
312        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// Hash material structures
331#[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}