use std::collections::HashMap;
use compact_str::CompactString;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const EXTENSION_RESPONSE_LOG_FIELDS: &[&str] = &["status", "rejectedReason", "reason", "code"];
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Extensions(HashMap<CompactString, ExtensionEntry>);
impl Extensions {
#[must_use]
pub fn new() -> Self {
Self(HashMap::new())
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn get(&self, id: &str) -> Option<&ExtensionEntry> {
self.0.get(id)
}
pub fn insert(&mut self, id: impl Into<CompactString>, entry: ExtensionEntry) {
let _ = self.0.insert(id.into(), entry);
}
#[must_use]
pub fn remove(&mut self, id: &str) -> Option<ExtensionEntry> {
self.0.remove(id)
}
pub fn iter(&self) -> impl Iterator<Item = (&CompactString, &ExtensionEntry)> {
self.0.iter()
}
pub fn extend(&mut self, other: Self) {
self.0.extend(other.0);
}
}
impl<K, V> FromIterator<(K, V)> for Extensions
where
K: Into<CompactString>,
V: Into<ExtensionEntry>,
{
fn from_iter<T: IntoIterator<Item = (K, V)>>(iter: T) -> Self {
Self(
iter.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ExtensionEntry {
Structured {
info: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
schema: Option<Value>,
#[serde(default, flatten)]
extra: serde_json::Map<String, Value>,
},
Raw(Value),
}
impl ExtensionEntry {
#[must_use]
pub fn info(info: Value) -> Self {
Self::Structured {
info,
schema: None,
extra: serde_json::Map::new(),
}
}
#[must_use]
pub fn with_schema(info: Value, schema: Value) -> Self {
Self::Structured {
info,
schema: Some(schema),
extra: serde_json::Map::new(),
}
}
#[must_use]
pub const fn raw(value: Value) -> Self {
Self::Raw(value)
}
#[must_use]
pub const fn as_info(&self) -> Option<&Value> {
match self {
Self::Structured { info, .. } => Some(info),
Self::Raw(_) => None,
}
}
#[must_use]
pub const fn as_schema(&self) -> Option<&Value> {
match self {
Self::Structured { schema, .. } => schema.as_ref(),
Self::Raw(_) => None,
}
}
#[must_use]
pub fn to_value(&self) -> Value {
match self {
Self::Structured {
info,
schema,
extra,
} => {
let mut obj = extra.clone();
let _ = obj.insert("info".to_owned(), info.clone());
if let Some(schema) = schema {
let _ = obj.insert("schema".to_owned(), schema.clone());
}
Value::Object(obj)
}
Self::Raw(value) => value.clone(),
}
}
}
impl From<Value> for ExtensionEntry {
fn from(value: Value) -> Self {
Self::Raw(value)
}
}
#[must_use]
pub fn schema_has_external_ref(value: &Value) -> bool {
match value {
Value::Array(items) => items.iter().any(schema_has_external_ref),
Value::Object(map) => {
for (key, child) in map {
if (key == "$ref" || key == "$id")
&& !child.as_str().is_some_and(|s| s.starts_with('#'))
{
return true;
}
if schema_has_external_ref(child) {
return true;
}
}
false
}
_ => false,
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::indexing_slicing,
reason = "unit tests panic on assertion failure"
)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn extensions_empty_by_default() {
let ext = Extensions::new();
assert!(ext.is_empty());
assert_eq!(serde_json::to_value(&ext).unwrap(), json!({}));
}
#[test]
fn structured_entry_roundtrip() {
let mut ext = Extensions::new();
ext.insert(
"bazaar",
ExtensionEntry::with_schema(json!({"registered": true}), json!({"type": "object"})),
);
let encoded = serde_json::to_value(&ext).unwrap();
assert_eq!(encoded["bazaar"]["info"]["registered"], true);
assert_eq!(encoded["bazaar"]["schema"]["type"], "object");
let decoded: Extensions = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded, ext);
}
#[test]
fn raw_entry_roundtrip() {
let mut ext = Extensions::new();
ext.insert("custom", ExtensionEntry::raw(json!([1, 2, 3])));
let encoded = serde_json::to_value(&ext).unwrap();
assert_eq!(encoded["custom"], json!([1, 2, 3]));
let decoded: Extensions = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded, ext);
}
#[test]
fn log_field_allowlist() {
assert_eq!(
EXTENSION_RESPONSE_LOG_FIELDS,
["status", "rejectedReason", "reason", "code"]
);
}
#[test]
fn structured_preserves_sibling_fields() {
let encoded = json!({
"sign-in-with-x": {
"info": {"domain": "api.example.com"},
"supportedChains": [{"chainId": "eip155:8453", "type": "eip191"}]
}
});
let decoded: Extensions = serde_json::from_value(encoded).unwrap();
let value = decoded.get("sign-in-with-x").unwrap().to_value();
assert_eq!(value["info"]["domain"], "api.example.com");
assert_eq!(value["supportedChains"][0]["chainId"], "eip155:8453");
assert_eq!(value["supportedChains"][0]["type"], "eip191");
}
#[test]
fn schema_has_external_ref_http_and_file() {
assert!(schema_has_external_ref(
&json!({"$ref": "http://127.0.0.1/attacker-schema.json"})
));
assert!(schema_has_external_ref(
&json!({"$ref": "file:///etc/passwd"})
));
assert!(schema_has_external_ref(
&json!({"$id": "https://evil.example/x.json"})
));
assert!(schema_has_external_ref(
&json!({"$ref": "../../etc/passwd"})
));
}
#[test]
fn schema_has_external_ref_nested_and_non_string() {
assert!(schema_has_external_ref(&json!({
"properties": { "input": { "$ref": "http://evil.example/schema.json" } }
})));
assert!(schema_has_external_ref(
&json!({"allOf": [{"type": "object"}, {"$ref": "http://evil.example/x.json"}]})
));
assert!(schema_has_external_ref(&json!({"$ref": 1})));
assert!(schema_has_external_ref(&json!({"$id": true})));
}
#[test]
fn schema_has_external_ref_allows_fragments_and_schema_url() {
assert!(!schema_has_external_ref(&json!({
"$schema": "https://json-schema.org/draft/2020-12/schema"
})));
assert!(!schema_has_external_ref(
&json!({"$ref": "#/definitions/root"})
));
assert!(!schema_has_external_ref(&json!({"$id": "#"})));
assert!(!schema_has_external_ref(&json!({})));
assert!(!schema_has_external_ref(&json!("https://example.com")));
assert!(!schema_has_external_ref(&Value::Null));
}
}