use super::types::{CacheLabsWrapper, CacheUser}; use sha2::{Digest, Sha256}; // --------------------------------------------------------------------------- // Python-compatible JSON serialization helpers // // Python's default json.dumps uses separators=(', ', ': ') and // ensure_ascii=True. Field order follows dataclass definition order. // The digest must be byte-for-byte identical to Python's CacheData.__calc_digest. // --------------------------------------------------------------------------- /// Escape a string in Python json.dumps style: /// - Special chars: `"`, `\`, and control chars → standard JSON escapes /// - Non-ASCII chars → `\uXXXX` (matches Python's `ensure_ascii=True` default) fn python_json_string(s: &str) -> String { let mut out = String::with_capacity(s.len() + 2); out.push('"'); for c in s.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), c if (c as u32) < 0x20 => { out.push_str(&format!("\\u{:04x}", c as u32)); } c if c.is_ascii() => out.push(c), c => { // Non-ASCII: BMP → \uXXXX, outside BMP → surrogate pair let code = c as u32; if code <= 0xFFFF { out.push_str(&format!("\\u{:04x}", code)); } else { let code = code - 0x10000; let high = 0xD800 + (code >> 10); let low = 0xDC00 + (code & 0x3FF); out.push_str(&format!("\\u{:04x}\\u{:04x}", high, low)); } } } } out.push('"'); out } /// Serialize a `u32` slice as a Python-style JSON array: `[1, 2, 3]`. fn python_json_u32_array(items: &[u32]) -> String { if items.is_empty() { return "[]".to_string(); } let inner: Vec = items.iter().map(|x| x.to_string()).collect(); format!("[{}]", inner.join(", ")) } /// Build the JSON array string that Python's `__calc_digest` hashes: /// `[user_asdict_or_null, token_asdict, labs_asdict]` /// /// Field order matches each Python dataclass definition: /// User: id, username, laboratory_ids, is_reviewer /// Token: access, refresh /// Laboratories: items /// Laboratory: id, name, pi_name, full_name pub fn python_digest_json( user: Option<&CacheUser>, access: &str, refresh: &str, labs: &CacheLabsWrapper, ) -> String { let user_str = match user { None => "null".to_string(), Some(u) => format!( "{{\"id\": {}, \"username\": {}, \"laboratory_ids\": {}, \"is_reviewer\": {}}}", u.id, python_json_string(&u.username), python_json_u32_array(&u.laboratory_ids), if u.is_reviewer { "true" } else { "false" } ), }; let token_str = format!( "{{\"access\": {}, \"refresh\": {}}}", python_json_string(access), python_json_string(refresh) ); let items: Vec = labs .items .iter() .map(|lab| { format!( "{{\"id\": {}, \"name\": {}, \"pi_name\": {}, \"full_name\": {}}}", lab.id, python_json_string(&lab.name), python_json_string(&lab.pi_name), python_json_string(&lab.full_name) ) }) .collect(); let items_str = if items.is_empty() { "[]".to_string() } else { format!("[{}]", items.join(", ")) }; let labs_str = format!("{{\"items\": {}}}", items_str); format!("[{}, {}, {}]", user_str, token_str, labs_str) } /// Compute the cache digest compatible with Python's `CacheData.__calc_digest`: /// `hashlib.sha256(json.dumps([user, token, labs]).encode("utf-8")).hexdigest()` pub fn compute_digest( user: Option<&CacheUser>, access: &str, refresh: &str, labs: &CacheLabsWrapper, ) -> String { let json_str = python_digest_json(user, access, refresh, labs); let mut hasher = Sha256::new(); hasher.update(json_str.as_bytes()); format!("{:x}", hasher.finalize()) }