use blake3;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use unicode_normalization::UnicodeNormalization;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StableHashConfig {
pub recipe: String,
pub algorithm: HashAlgorithm,
pub use_cache: bool,
pub salt: Option<String>,
}
impl Default for StableHashConfig {
fn default() -> Self {
Self {
recipe: "v1".to_string(),
algorithm: HashAlgorithm::Blake3,
use_cache: true,
salt: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum HashAlgorithm {
Sha256,
Blake3,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HashRecipe {
pub fields: Vec<String>,
pub normalize: NormalizeOptions,
pub salt: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NormalizeOptions {
pub unicode: UnicodeForm,
pub trim: bool,
pub case: CaseNormalization,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum UnicodeForm {
NFC,
NFD,
NFKC,
NFKD,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CaseNormalization {
AsIs,
Lower,
Upper,
}
pub struct StableHashGenerator {
config: StableHashConfig,
recipes: IndexMap<String, HashRecipe>,
cache: IndexMap<String, String>,
}
impl StableHashGenerator {
pub fn new(config: StableHashConfig) -> Self {
Self {
config,
recipes: Self::load_recipes(),
cache: IndexMap::new(),
}
}
pub fn generate_release_id(
&mut self,
upc: &str,
release_type: &str,
track_isrcs: &[String],
territory_set: &[String],
) -> Result<String, super::error::BuildError> {
let materials = ReleaseHashMaterials {
upc: upc.to_string(),
release_type: release_type.to_string(),
track_isrcs: track_isrcs.to_vec(),
territory_set: territory_set.to_vec(),
};
self.generate("Release", &materials)
}
pub fn generate_resource_id(
&mut self,
isrc: &str,
duration: u32,
file_hash: Option<&str>,
) -> Result<String, super::error::BuildError> {
let materials = ResourceHashMaterials {
isrc: isrc.to_string(),
duration,
file_hash: file_hash.map(|s| s.to_string()),
};
self.generate("Resource", &materials)
}
pub fn generate_party_id(
&mut self,
name: &str,
role: &str,
identifiers: &[String],
) -> Result<String, super::error::BuildError> {
let materials = PartyHashMaterials {
name: name.to_string(),
role: role.to_string(),
identifiers: identifiers.to_vec(),
};
self.generate("Party", &materials)
}
fn generate<T: Serialize>(
&mut self,
entity_type: &str,
materials: &T,
) -> Result<String, super::error::BuildError> {
let cache_key = format!("{}:{}", entity_type, serde_json::to_string(materials)?);
if self.config.use_cache {
if let Some(cached) = self.cache.get(&cache_key) {
return Ok(cached.clone());
}
}
let recipe = self
.recipes
.get(&format!("{}.{}", entity_type, self.config.recipe))
.ok_or_else(|| super::error::BuildError::InvalidFormat {
field: "recipe".to_string(),
message: format!("No recipe for {}.{}", entity_type, self.config.recipe),
})?;
let normalized = self.normalize_materials(materials, recipe)?;
let id = match self.config.algorithm {
HashAlgorithm::Sha256 => self.hash_sha256(&normalized, &recipe.salt),
HashAlgorithm::Blake3 => self.hash_blake3(&normalized, &recipe.salt),
};
if self.config.use_cache {
self.cache.insert(cache_key, id.clone());
}
Ok(id)
}
fn normalize_materials<T: Serialize>(
&self,
materials: &T,
recipe: &HashRecipe,
) -> Result<String, super::error::BuildError> {
let json = serde_json::to_value(materials)?;
let mut parts = Vec::new();
for field in &recipe.fields {
if let Some(value) = json.get(field) {
let normalized = self.normalize_value(value, &recipe.normalize)?;
parts.push(normalized);
}
}
Ok(parts.join("|"))
}
fn normalize_value(
&self,
value: &serde_json::Value,
options: &NormalizeOptions,
) -> Result<String, super::error::BuildError> {
let text = match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Array(arr) => {
let strings: Vec<String> = arr
.iter()
.map(|v| self.normalize_value(v, options))
.collect::<Result<Vec<_>, _>>()?;
strings.join(",")
}
_ => serde_json::to_string(value)?,
};
let mut normalized = text;
normalized = match options.unicode {
UnicodeForm::NFC => normalized.nfc().collect(),
UnicodeForm::NFD => normalized.nfd().collect(),
UnicodeForm::NFKC => normalized.nfkc().collect(),
UnicodeForm::NFKD => normalized.nfkd().collect(),
};
if options.trim {
normalized = normalized.trim().to_string();
}
normalized = match options.case {
CaseNormalization::AsIs => normalized,
CaseNormalization::Lower => normalized.to_lowercase(),
CaseNormalization::Upper => normalized.to_uppercase(),
};
Ok(normalized)
}
fn hash_sha256(&self, input: &str, salt: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(salt.as_bytes());
hasher.update(input.as_bytes());
if let Some(global_salt) = &self.config.salt {
hasher.update(global_salt.as_bytes());
}
let result = hasher.finalize();
format!("SHA256:{:x}", result)
}
fn hash_blake3(&self, input: &str, salt: &str) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(salt.as_bytes());
hasher.update(input.as_bytes());
if let Some(global_salt) = &self.config.salt {
hasher.update(global_salt.as_bytes());
}
let hash = hasher.finalize();
format!("B3:{}", hash.to_hex())
}
fn load_recipes() -> IndexMap<String, HashRecipe> {
let mut recipes = IndexMap::new();
recipes.insert(
"Release.v1".to_string(),
HashRecipe {
fields: vec![
"upc".to_string(),
"release_type".to_string(),
"track_isrcs".to_string(),
"territory_set".to_string(),
],
normalize: NormalizeOptions {
unicode: UnicodeForm::NFC,
trim: true,
case: CaseNormalization::AsIs,
},
salt: "REL@1".to_string(),
},
);
recipes.insert(
"Resource.v1".to_string(),
HashRecipe {
fields: vec![
"isrc".to_string(),
"duration".to_string(),
"file_hash".to_string(),
],
normalize: NormalizeOptions {
unicode: UnicodeForm::NFC,
trim: true,
case: CaseNormalization::AsIs,
},
salt: "RES@1".to_string(),
},
);
recipes.insert(
"Party.v1".to_string(),
HashRecipe {
fields: vec![
"name".to_string(),
"role".to_string(),
"identifiers".to_string(),
],
normalize: NormalizeOptions {
unicode: UnicodeForm::NFC,
trim: true,
case: CaseNormalization::Lower,
},
salt: "PTY@1".to_string(),
},
);
recipes
}
}
#[derive(Debug, Serialize)]
struct ReleaseHashMaterials {
upc: String,
release_type: String,
track_isrcs: Vec<String>,
territory_set: Vec<String>,
}
#[derive(Debug, Serialize)]
struct ResourceHashMaterials {
isrc: String,
duration: u32,
file_hash: Option<String>,
}
#[derive(Debug, Serialize)]
struct PartyHashMaterials {
name: String,
role: String,
identifiers: Vec<String>,
}