diff --git a/crates/torii/sqlite/src/executor/erc.rs b/crates/torii/sqlite/src/executor/erc.rs index 42e18522d8..06cd40cf26 100644 --- a/crates/torii/sqlite/src/executor/erc.rs +++ b/crates/torii/sqlite/src/executor/erc.rs @@ -18,7 +18,7 @@ use crate::executor::LOG_TARGET; use crate::simple_broker::SimpleBroker; use crate::types::{ContractType, TokenBalance}; use crate::utils::{ - felt_to_sql_string, fetch_content_from_ipfs, sql_string_to_u256, u256_to_sql_string, I256, + felt_to_sql_string, fetch_content_from_ipfs, sanitize_json_string, sql_string_to_u256, u256_to_sql_string, I256 }; #[derive(Debug, Clone)] @@ -313,14 +313,17 @@ impl<'c, P: Provider + Sync + Send + 'static> Executor<'c, P> { } let decoded = data_url.decode_to_vec().context("Failed to decode data URI")?; - // Filter out control characters and escape unescaped quotes + // HACK: Loot Survior NFT metadata contains control characters which makes the json + // DATA invalid so filter them out let decoded_str = String::from_utf8_lossy(&decoded.0) .chars() .filter(|c| !c.is_ascii_control()) - .collect::() - .replace(r#"""#, r#"\""#); // Escape unescaped quotes + .collect::(); + let sanitized_json = sanitize_json_string(&decoded_str); - let json: serde_json::Value = serde_json::from_str(&decoded_str) + println!("sanitized_json: {}", sanitized_json); + + let json: serde_json::Value = serde_json::from_str(&sanitized_json) .with_context(|| format!("Failed to parse metadata JSON from data URI: {}", &uri))?; Ok(json) diff --git a/crates/torii/sqlite/src/utils.rs b/crates/torii/sqlite/src/utils.rs index fd77affa82..86d0c08a5b 100644 --- a/crates/torii/sqlite/src/utils.rs +++ b/crates/torii/sqlite/src/utils.rs @@ -53,6 +53,58 @@ pub fn sql_string_to_felts(sql_string: &str) -> Vec { sql_string.split(SQL_FELT_DELIMITER).map(|felt| Felt::from_str(felt).unwrap()).collect() } +/// Sanitizes a JSON string by escaping unescaped double quotes within string values. +pub fn sanitize_json_string(s: &str) -> String { + let mut result = String::new(); + let mut chars = s.chars().peekable(); + let mut in_string = false; + + while let Some(c) = chars.next() { + match c { + '"' => { + if !in_string { + // Starting a string + result.push('"'); + in_string = true; + } else { + // Check next char to see if this is the end of the string + match chars.peek() { + Some(&':') | Some(&',') | Some(&'}') => { + // This is end of a JSON string + result.push('"'); + in_string = false; + } + _ => { + // This is an internal quote that needs escaping + result.push_str("\\\""); + } + } + } + } + '\\' => { + if let Some(&next) = chars.peek() { + if next == '"' { + // Already escaped quote, preserve it without adding extra escapes + result.push('\\'); + result.push('"'); + chars.next(); // Consume the quote + } else { + // Regular backslash + result.push('\\'); + } + } else { + result.push('\\'); + } + } + _ => { + result.push(c); + } + } + } + + result +} + pub async fn fetch_content_from_ipfs(cid: &str) -> Result { let mut retries = IPFS_CLIENT_MAX_RETRY; let client = IpfsClient::from_str(IPFS_CLIENT_URL)? @@ -166,6 +218,20 @@ mod tests { use super::*; + #[test] + fn test_sanitize_json_string() { + let input = r#"{"name":""Rage Shout" DireWolf"}"#; + let expected = r#"{"name":"\"Rage Shout\" DireWolf"}"#; + let sanitized = sanitize_json_string(input); + assert_eq!(sanitized, expected); + + let input_escaped = r#"{"name":"\"Properly Escaped\" Wolf"}"#; + let expected_escaped = r#"{"name":"\"Properly Escaped\" Wolf"}"#; + let sanitized_escaped = sanitize_json_string(input_escaped); + assert_eq!(sanitized_escaped, expected_escaped); + } + + #[test] fn test_must_utc_datetime_from_timestamp() { let timestamp = 1633027200;