769a5a68e2
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>
124 lines
4.2 KiB
Rust
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())
|
|
}
|