b7f3061be3
- Tauri 2 + Svelte 5 hub in ODIAN dark: patch notes and roadmap, install and update progress, local stack status, settings with DHP and MCP addresses. - crates/odian-packs: Rust installer for pack format v1 (signed manifests, resumable verified downloads from a DHP or an ODIAN drive), byte-compatible with ODIAN_MCP_Server's Python odian_packs. - Local stack on 127.0.0.1: Postgres -> Engine API -> tile server -> Odian.exe, one Job Object, clean shutdown; database snapshot restore on a fresh PC only. - Keycloak sign-in through the DHP's MCP, remembered until Sign out with a launcher session that renews the Engine token silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
117 lines
4.3 KiB
Rust
117 lines
4.3 KiB
Rust
//! Canonical JSON: the one serialisation that gets hashed or signed.
|
|
//!
|
|
//! Byte-for-byte equal to Python's
|
|
//! `json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")`.
|
|
//!
|
|
//! Object keys are sorted HERE, by code point (UTF-8 byte order is the same order).
|
|
//! Nothing relies on `serde_json::Map` iteration order, which changes when any crate in
|
|
//! the build enables serde_json's `preserve_order` feature.
|
|
//!
|
|
//! Numbers must be integers in the i64/u64 range. Floats (`1.0`, `1e3`, `-0`) and integers
|
|
//! outside that range are refused: Python would serialise them, but no pack document holds
|
|
//! one, and refusing them keeps float formatting out of the signed bytes.
|
|
|
|
use serde_json::Value;
|
|
|
|
use crate::error::{Error, Result};
|
|
|
|
/// Canonical bytes of `value`, or [`Error::Manifest`] if it holds a non-integer number.
|
|
pub fn canonical_bytes(value: &Value) -> Result<Vec<u8>> {
|
|
let mut out = Vec::with_capacity(1024);
|
|
write_value(value, &mut out)?;
|
|
Ok(out)
|
|
}
|
|
|
|
fn write_value(value: &Value, out: &mut Vec<u8>) -> Result<()> {
|
|
match value {
|
|
Value::Null => out.extend_from_slice(b"null"),
|
|
Value::Bool(true) => out.extend_from_slice(b"true"),
|
|
Value::Bool(false) => out.extend_from_slice(b"false"),
|
|
Value::Number(n) => {
|
|
if let Some(u) = n.as_u64() {
|
|
out.extend_from_slice(u.to_string().as_bytes());
|
|
} else if let Some(i) = n.as_i64() {
|
|
out.extend_from_slice(i.to_string().as_bytes());
|
|
} else {
|
|
return Err(Error::manifest(format!(
|
|
"number {n} is not an integer in the 64-bit range; pack documents hold integers only"
|
|
)));
|
|
}
|
|
}
|
|
Value::String(s) => write_str(s, out),
|
|
Value::Array(items) => {
|
|
out.push(b'[');
|
|
for (i, item) in items.iter().enumerate() {
|
|
if i > 0 {
|
|
out.push(b',');
|
|
}
|
|
write_value(item, out)?;
|
|
}
|
|
out.push(b']');
|
|
}
|
|
Value::Object(map) => {
|
|
let mut entries: Vec<(&String, &Value)> = map.iter().collect();
|
|
entries.sort_unstable_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
|
|
out.push(b'{');
|
|
for (i, (key, item)) in entries.into_iter().enumerate() {
|
|
if i > 0 {
|
|
out.push(b',');
|
|
}
|
|
write_str(key, out);
|
|
out.push(b':');
|
|
write_value(item, out)?;
|
|
}
|
|
out.push(b'}');
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Python's `encode_basestring` (the `ensure_ascii=False` escaper).
|
|
fn write_str(s: &str, out: &mut Vec<u8>) {
|
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
|
out.push(b'"');
|
|
// Escapes only ever replace ASCII bytes, and bytes of multi-byte UTF-8 sequences are
|
|
// all >= 0x80, so a byte loop copies every other character through untouched.
|
|
for &b in s.as_bytes() {
|
|
match b {
|
|
b'"' => out.extend_from_slice(b"\\\""),
|
|
b'\\' => out.extend_from_slice(b"\\\\"),
|
|
b'\n' => out.extend_from_slice(b"\\n"),
|
|
b'\r' => out.extend_from_slice(b"\\r"),
|
|
b'\t' => out.extend_from_slice(b"\\t"),
|
|
0x08 => out.extend_from_slice(b"\\b"),
|
|
0x0c => out.extend_from_slice(b"\\f"),
|
|
0x00..=0x1f => {
|
|
out.extend_from_slice(b"\\u00");
|
|
out.push(HEX[(b >> 4) as usize]);
|
|
out.push(HEX[(b & 0x0f) as usize]);
|
|
}
|
|
_ => out.push(b),
|
|
}
|
|
}
|
|
out.push(b'"');
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn sorts_and_escapes_like_python() {
|
|
let v = json!({"b": 1, "a": [1, {"d": 2, "c": 3}]});
|
|
assert_eq!(canonical_bytes(&v).unwrap(), br#"{"a":[1,{"c":3,"d":2}],"b":1}"#);
|
|
let v = json!({"s": "q\"b\\n\n\u{1}\u{7f}é/"});
|
|
assert_eq!(canonical_bytes(&v).unwrap(), "{\"s\":\"q\\\"b\\\\n\\n\\u0001\u{7f}é/\"}".as_bytes());
|
|
}
|
|
|
|
#[test]
|
|
fn refuses_floats() {
|
|
for text in ["1.0", "1e3", "-0", "[0.5]", "18446744073709551616"] {
|
|
let v: Value = serde_json::from_str(text).unwrap();
|
|
assert!(canonical_bytes(&v).is_err(), "{text}");
|
|
}
|
|
}
|
|
}
|