Repository: GREsau/schemars
Pull request #436 (2025-06-21 21:49:20)

Correctly encode names used in `$ref` values
`$ref` values are URI references, usually a URI fragment identifier representing a JSON pointer.

`~` and `/` have special meaning in JSON Pointers, so need encoding to `~0` and `~1` respectively (see https://datatracker.ietf.org/doc/html/rfc6901#section-3).

Some other characters are not valid in a fragment, so need percent-encoding (see https://datatracker.ietf.org/doc/html/rfc3986/#section-3.5).


Patch:
diff --git a/schemars/src/generate.rs b/schemars/src/generate.rs
index 6d2c0fe..7716358 100644
--- a/schemars/src/generate.rs
+++ b/schemars/src/generate.rs
@@ -12,6 +12,7 @@ use crate::Schema;
 use crate::_alloc_prelude::*;
 use crate::{transform::*, JsonSchema};
 use alloc::collections::{BTreeMap, BTreeSet};
+use core::fmt::Write;
 use core::{any::Any, fmt::Debug};
 use dyn_clone::DynClone;
 use serde::Serialize;
@@ -327,7 +328,11 @@ impl SchemaGenerator {
                     name
                 });
 
-            let reference = format!("#{}/{}", self.definitions_path_stripped(), name);
+            let reference = format!(
+                "#{}/{}",
+                self.definitions_path_stripped(),
+                encode_ref_name(&name)
+            );
             if !self.definitions.contains_key(name.as_ref()) {
                 self.insert_new_subschema_for::<T>(name, &uid);
             }
@@ -572,6 +577,41 @@ impl SchemaGenerator {
     }
 }
 
+fn encode_ref_name(name: &str) -> alloc::borrow::Cow<str> {
+    fn needs_encoding(byte: u8) -> bool {
+        match byte {
+            // `~` and `/` need encoding for JSON Pointer
+            // See https://datatracker.ietf.org/doc/html/rfc6901#section-3
+            b'~' | b'/' => true,
+            // These chars (and `~`) are valid in URL fragment
+            // See https://datatracker.ietf.org/doc/html/rfc3986/#section-3.5
+            b'!' | b'"' | b'$' | b'&'..=b';' | b'=' | b'?'..=b'Z' | b'_' | b'a'..=b'z' => false,
+            // Everything else needs percent-encoding
+            _ => true,
+        }
+    }
+
+    if name.bytes().any(needs_encoding) {
+        let mut buf = String::new();
+
+        for byte in name.bytes() {
+            if byte == b'~' {
+                buf.push_str("~0");
+            } else if byte == b'/' {
+                buf.push_str("~1");
+            } else if needs_encoding(byte) {
+                write!(buf, "%{byte:2x}").unwrap();
+            } else {
+                buf.push(byte as char);
+            }
+        }
+
+        buf.into()
+    } else {
+        name.into()
+    }
+}
+
 fn json_pointer_mut<'a>(
     mut object: &'a mut JsonMap<String, Value>,
     pointer: &str,


Test patch:
diff --git a/schemars/tests/integration/schema_name.rs b/schemars/tests/integration/schema_name.rs
index 3536de7..beba6f3 100644
--- a/schemars/tests/integration/schema_name.rs
+++ b/schemars/tests/integration/schema_name.rs
@@ -30,7 +30,7 @@ struct TypeParams<T, U, V, W> {
 }
 
 #[derive(JsonSchema, Deserialize, Serialize, Default)]
-#[schemars(rename = "new-name-{W}-{T}-{T}")]
+#[schemars(rename = "new name~{W}/{T}/{T}!")]
 struct RenamedTypeParams<T, U, V, W> {
     t: T,
     u: U,
@@ -55,7 +55,7 @@ fn type_params() {
         .custom(|schema, _| {
             assert_eq!(
                 schema.get("title"),
-                Some(&"new-name-null-uint8-uint8".into())
+                Some(&"new name~null/uint8/uint8!".into())
             )
         });
 
@@ -63,6 +63,19 @@ fn type_params() {
         <RenamedTypeParams<u8, String, bool, ()>>::schema_id(),
         <RenamedTypeParams<(), u8, String, bool>>::schema_id()
     );
+
+    test!((
+        TypeParams<u8, String, bool, ()>,
+        RenamedTypeParams<u8, String, bool, ()>
+    ))
+    .assert_allows_ser_roundtrip_default()
+    .custom(|schema, _| {
+        assert_eq!(
+            schema.pointer("/prefixItems/1/$ref"),
+            Some(&("#/$defs/new%20name~0null~1uint8~1uint8!".into())),
+            "name should be correctly encoded for $ref value"
+        )
+    });
 }
 
 #[derive(JsonSchema, Deserialize, Serialize, Default)]