runx-contracts 0.6.19

Shared Rust contract types for runx JSON and host protocol boundaries.
Documentation
use std::fmt::Write as _;

use crate::{JsonNumber, JsonValue};

pub(super) fn stable_hash_json(value: &JsonValue) -> String {
    let mut json = String::new();
    append_stable_hash_json(value, &mut json);
    json
}

fn append_stable_hash_json(value: &JsonValue, json: &mut String) {
    match value {
        JsonValue::Null => json.push_str("null"),
        JsonValue::Bool(value) => json.push_str(if *value { "true" } else { "false" }),
        JsonValue::Number(value) => append_json_number(value, json),
        JsonValue::String(value) => append_json_string(value, json),
        JsonValue::Array(values) => {
            json.push('[');
            for (index, value) in values.iter().enumerate() {
                if index > 0 {
                    json.push(',');
                }
                append_stable_hash_json(value, json);
            }
            json.push(']');
        }
        JsonValue::Object(values) => {
            json.push('{');
            for (index, (key, value)) in values.iter().enumerate() {
                if index > 0 {
                    json.push(',');
                }
                append_json_string(key, json);
                json.push(':');
                append_stable_hash_json(value, json);
            }
            json.push('}');
        }
    }
}

fn append_json_number(value: &JsonNumber, json: &mut String) {
    match value {
        JsonNumber::I64(value) => {
            let _ = write!(json, "{value}");
        }
        JsonNumber::U64(value) => {
            let _ = write!(json, "{value}");
        }
        JsonNumber::F64(value) if value.is_finite() && *value == 0.0 => json.push('0'),
        JsonNumber::F64(value) if value.is_finite() && value.fract() == 0.0 => {
            let _ = write!(json, "{value:.0}");
        }
        JsonNumber::F64(value) if value.is_finite() => {
            let _ = write!(json, "{value}");
        }
        // `hashStable` follows TypeScript's JSON.stringify behavior here.
        // Public serde serialization stays strict and rejects non-finite JSON
        // numbers, but idempotency hashing must match the TypeScript oracle.
        JsonNumber::F64(_) => json.push_str("null"),
    }
}

fn append_json_string(value: &str, json: &mut String) {
    json.push('"');
    for character in value.chars() {
        match character {
            '"' => json.push_str("\\\""),
            '\\' => json.push_str("\\\\"),
            '\u{08}' => json.push_str("\\b"),
            '\u{0c}' => json.push_str("\\f"),
            '\n' => json.push_str("\\n"),
            '\r' => json.push_str("\\r"),
            '\t' => json.push_str("\\t"),
            character if character <= '\u{1f}' => {
                let _ = write!(json, "\\u{:04x}", u32::from(character));
            }
            // Current Node/V8 JSON.stringify emits U+2028 and U+2029 raw.
            character => json.push(character),
        }
    }
    json.push('"');
}

pub(super) fn sha256_prefixed(value: &str) -> String {
    crate::fingerprint::sha256_prefixed(value.as_bytes())
}

#[cfg(test)]
mod tests {
    use super::stable_hash_json;
    use crate::act::assignment::derive_content_hash;
    use crate::{JsonNumber, JsonValue};

    #[test]
    fn stable_hash_json_matches_json_stringify_for_non_finite_numbers() {
        assert_eq!(
            stable_hash_json(&JsonValue::Number(JsonNumber::F64(f64::NAN))),
            "null",
        );
        assert_eq!(
            derive_content_hash(Some(
                [(
                    "value".to_owned(),
                    JsonValue::Number(JsonNumber::F64(f64::INFINITY)),
                )]
                .into_iter()
                .collect(),
            )),
            "sha256:1c197daef20de3f47eec5e2f735ec6669869d3180cc29f35be4788511e0af0f8",
        );
    }

    #[test]
    fn stable_hash_json_matches_json_stringify_for_special_strings() {
        assert_eq!(
            stable_hash_json(&JsonValue::String("line\u{2028}sep\u{2029}end".to_owned())),
            "\"line\u{2028}sep\u{2029}end\"",
        );
    }

    #[test]
    fn stable_hash_json_matches_json_stringify_for_negative_zero() {
        assert_eq!(
            stable_hash_json(&JsonValue::Number(JsonNumber::F64(-0.0))),
            "0",
        );
    }
}