Files
mdrs-client-rust/src/cache/digest.rs
T
orrisroot 769a5a68e2 refactor: unify error handling with anyhow and add From conversions
Phase 5: Replace all Box<dyn Error> return types with anyhow::Result<T>
throughout the codebase. Replace string-based Err("msg".into()) and
format!().into() patterns with bail!() and anyhow!() macros. Fix
dirs::home_dir().unwrap() in settings.rs to use a fallback path instead
of panicking when HOME is unset. Remove stray use std::error::Error
imports no longer needed.

Phase 6: Add From<&User> for CacheUser in models/user.rs and
From<&Laboratory>/From<&Laboratories> for CacheLaboratory/CacheLabsWrapper
in models/laboratory.rs. Simplify commands/login.rs to use .into()
conversions, removing the redundant to_cache_user() and to_cache_labs()
helper functions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 14:19:10 +09:00

124 lines
4.2 KiB
Rust

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<String> = 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<String> = 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())
}