entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! JSON value representation.
//!
//! [`JsonValue`] models the six JSON types defined by RFC 8259. Objects
//! use a `Vec<(String, JsonValue)>` to preserve insertion order, which
//! matters for reproducible serialisation in test fixtures and debugging.

use super::parser::JsonParseError;

// ---------------------------------------------------------------------------
// Value type
// ---------------------------------------------------------------------------

/// A parsed JSON value.
///
/// This enum represents the complete JSON data model. Objects are stored
/// as ordered key-value pairs rather than a hash map — this preserves the
/// document order (useful for debugging and round-trip fidelity) and avoids
/// pulling in a hash map implementation for a parser that only needs
/// sequential access.
// NOTE: `Eq` is intentionally not derived because `JsonValue::Number` wraps
// `f64`, which does not implement `Eq` (NaN != NaN per IEEE 754).
#[doc(alias = "json")]
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum JsonValue {
    /// JSON `null`.
    Null,
    /// JSON boolean (`true` or `false`).
    Bool(bool),
    /// JSON number (IEEE 754 double precision).
    Number(f64),
    /// JSON string (UTF-8, escape sequences resolved).
    String(String),
    /// JSON array.
    Array(Vec<JsonValue>),
    /// JSON object (ordered key-value pairs).
    Object(Vec<(String, JsonValue)>),
}

// ---------------------------------------------------------------------------
// Accessor methods
// ---------------------------------------------------------------------------

impl JsonValue {
    /// Parses a JSON string into a [`JsonValue`].
    ///
    /// This is a convenience wrapper around the internal parser. Input must
    /// be valid UTF-8 and comply with the size and depth limits defined in
    /// the internal parser.
    ///
    /// # Errors
    ///
    /// Returns [`JsonParseError`] if the input is not valid JSON or
    /// exceeds security limits.
    ///
    /// # Examples
    ///
    /// ```
    /// use entropy_auth::JsonValue;
    ///
    /// let v = JsonValue::parse(r#"{"token_type":"Bearer","expires_in":3600}"#).unwrap();
    /// assert_eq!(v.get_str("token_type"), Some("Bearer"));
    ///
    /// assert!(JsonValue::parse("{ not json").is_err());
    /// ```
    #[must_use = "parsing may fail; handle the Result"]
    pub fn parse(input: &str) -> Result<Self, JsonParseError> {
        super::parser::parse(input)
    }

    /// Returns the string contents if this value is a [`JsonValue::String`].
    #[must_use]
    #[inline]
    pub fn as_str(&self) -> Option<&str> {
        match self {
            Self::String(s) => Some(s),
            _ => None,
        }
    }

    /// Returns the number as `f64` if this value is a [`JsonValue::Number`].
    #[must_use]
    #[inline]
    pub fn as_f64(&self) -> Option<f64> {
        match self {
            Self::Number(n) => Some(*n),
            _ => None,
        }
    }

    /// Returns the number as `i64` if this value is an integer-valued
    /// [`JsonValue::Number`] (i.e. the `f64` has no fractional part and
    /// fits within `i64` range).
    #[must_use]
    #[inline]
    #[allow(
        clippy::cast_possible_truncation,
        clippy::float_cmp,
        clippy::cast_precision_loss
    )]
    pub fn as_i64(&self) -> Option<i64> {
        match self {
            Self::Number(n) => {
                // Accept only finite integers in [i64::MIN, i64::MAX]. The
                // upper bound is EXCLUSIVE of 2^63: `i64::MAX as f64` rounds
                // *up* to 2^63, so a naive `(i as f64) == *n` round-trip guard
                // would accept the out-of-range input 2^63 and return a
                // silently-wrong saturated `i64::MAX`. Compare against 2^63
                // directly. (2^63 and -2^63 are both exactly representable as
                // f64; i64::MAX itself is not, so a JSON `9223372036854775807`
                // parses to 2^63 and is correctly rejected rather than
                // mis-rounded.)
                const MIN: f64 = -9_223_372_036_854_775_808.0; // -2^63 == i64::MIN
                const LIMIT: f64 = 9_223_372_036_854_775_808.0; // 2^63, exclusive
                if n.is_finite() && n.fract() == 0.0 && *n >= MIN && *n < LIMIT {
                    return Some(*n as i64);
                }
                None
            }
            _ => None,
        }
    }

    /// Returns the boolean value if this is a [`JsonValue::Bool`].
    #[must_use]
    #[inline]
    pub fn as_bool(&self) -> Option<bool> {
        match self {
            Self::Bool(b) => Some(*b),
            _ => None,
        }
    }

    /// Returns the array contents if this value is a [`JsonValue::Array`].
    #[must_use]
    #[inline]
    pub fn as_array(&self) -> Option<&[JsonValue]> {
        match self {
            Self::Array(a) => Some(a),
            _ => None,
        }
    }

    /// Returns the object entries if this value is a [`JsonValue::Object`].
    #[must_use]
    #[inline]
    pub fn as_object(&self) -> Option<&[(String, JsonValue)]> {
        match self {
            Self::Object(o) => Some(o),
            _ => None,
        }
    }

    /// Looks up a value by key if this is a [`JsonValue::Object`].
    ///
    /// Performs a linear scan of the key-value pairs. This is efficient
    /// for the small objects typical of OAuth/OIDC responses.
    ///
    /// Returns the first matching entry. Duplicate keys cannot occur in
    /// objects produced by [`JsonValue::parse`] (the parser rejects them as
    /// a `DuplicateKey` error), so the first-match rule never masks a second
    /// occurrence — closing the duplicate-claim confusion vector (e.g. two
    /// `exp` or `aud` members) at the parse layer.
    #[must_use]
    #[inline]
    pub fn get(&self, key: &str) -> Option<&JsonValue> {
        match self {
            Self::Object(entries) => entries.iter().find(|(k, _)| k == key).map(|(_, v)| v),
            _ => None,
        }
    }

    /// Convenience: looks up a key and returns its string value.
    #[must_use]
    #[inline]
    pub fn get_str(&self, key: &str) -> Option<&str> {
        self.get(key).and_then(Self::as_str)
    }

    /// Convenience: looks up a key and returns its integer value.
    #[must_use]
    #[inline]
    pub fn get_i64(&self, key: &str) -> Option<i64> {
        self.get(key).and_then(Self::as_i64)
    }

    /// Convenience: looks up a key and returns its boolean value.
    #[must_use]
    #[inline]
    pub fn get_bool(&self, key: &str) -> Option<bool> {
        self.get(key).and_then(Self::as_bool)
    }

    /// Convenience: looks up a key and returns its `f64` value.
    #[must_use]
    #[inline]
    pub fn get_f64(&self, key: &str) -> Option<f64> {
        self.get(key).and_then(Self::as_f64)
    }

    /// Returns `true` if this value is [`JsonValue::Null`].
    #[must_use]
    #[inline]
    pub fn is_null(&self) -> bool {
        matches!(self, Self::Null)
    }
}