From 6a9308843b4c7aedc7ae0b7a089e911da0a7c7e2 Mon Sep 17 00:00:00 2001 From: Sang Date: Mon, 28 Sep 2026 20:13:24 +0700 Subject: [PATCH 1/2] Deep-dup SCALAR_SCHEMAS entries so exported schemas never share state scalar_schema and array_schema only shallow-`.dup`ed a SCALAR_SCHEMAS entry. `.freeze` on the constant is shallow, so nested Arrays/Hashes (e.g. :decimal's `"type" => %w[string number]`) stayed live and shared across every field exported from that entry. Mutating one field's schema (nullify! appending "null" for `nullable: true`) mutated the frozen constant itself, misdocumenting every :decimal field exported afterward in the process as nullable, with no error raised. Use `.deep_dup` (already used elsewhere in the gem) instead of `.dup` in both methods so each exported schema owns independent copies of any nested Array/Hash. Co-Authored-By: Claude Sonnet 5 --- lib/permittable/json_schema.rb | 14 ++++++++++++-- spec/json_schema_spec.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/lib/permittable/json_schema.rb b/lib/permittable/json_schema.rb index 0d29678..a0a5c9d 100644 --- a/lib/permittable/json_schema.rb +++ b/lib/permittable/json_schema.rb @@ -114,8 +114,14 @@ def nullify!(schema, field) schema end + # deep_dup, not dup: SCALAR_SCHEMAS entries like :decimal nest a live Array + # ("type" => %w[string number]) inside the frozen top-level Hash. .freeze + # is shallow, so a shallow .dup here would hand every :decimal field's + # exported schema the SAME Array — nullify! appending "null" to one + # field's "type" would then mutate the shared constant, misdocumenting + # every :decimal field exported afterward in the process as nullable. def scalar_schema(field) - schema = SCALAR_SCHEMAS.fetch(field[:type]).dup + schema = SCALAR_SCHEMAS.fetch(field[:type]).deep_dup apply_format_name!(schema, field) apply_in!(schema, field) apply_string_bounds!(schema, field) @@ -151,7 +157,11 @@ def array_schema(field, unknown:) min, max = length_bounds(field[:length]) schema["minItems"] = min if min schema["maxItems"] = max if max - schema["items"] = field[:fields] ? object(field[:fields], unknown: unknown) : SCALAR_SCHEMAS.fetch(field[:of]).dup + # deep_dup here for the same reason as scalar_schema: a scalar `of:` + # otherwise hands every array field the SAME nested Array/Hash from + # SCALAR_SCHEMAS. + schema["items"] = + field[:fields] ? object(field[:fields], unknown: unknown) : SCALAR_SCHEMAS.fetch(field[:of]).deep_dup schema end diff --git a/spec/json_schema_spec.rb b/spec/json_schema_spec.rb index 1b9d964..081ba4b 100644 --- a/spec/json_schema_spec.rb +++ b/spec/json_schema_spec.rb @@ -42,6 +42,19 @@ def quietly expect(props["day"]).to eq("type" => "string", "format" => "date") expect(props["at"]).to eq("type" => "string", "format" => "date-time") end + + it "deep-dups a SCALAR_SCHEMAS entry, so mutating one field's exported type array never leaks " \ + "into the frozen constant or any other field's schema" do + type_array = property("d") { optional :d, :decimal }["type"] + expect(type_array).not_to equal(Permittable::JsonSchema::SCALAR_SCHEMAS[:decimal]["type"]) + + type_array << "null" + + expect(Permittable::JsonSchema::SCALAR_SCHEMAS[:decimal]["type"]).to eq(%w[string number]) + expect(property("d2") { optional :d2, :decimal }["type"]).to eq(%w[string number]) + ensure + Permittable::JsonSchema::SCALAR_SCHEMAS[:decimal]["type"].delete("null") + end end describe "required and absence semantics" do @@ -649,6 +662,19 @@ def server_accepts?(field_rule, value) expect(items["required"]).to eq(%w[sku quantity]) expect(items["properties"]["quantity"]).to include("minimum" => 1, "maximum" => 99) end + + it "deep-dups a SCALAR_SCHEMAS entry for `of:`, so mutating one array field's items type never leaks " \ + "into the frozen constant or any other field's schema" do + items_type = property("ds") { array :ds, of: :decimal }["items"]["type"] + expect(items_type).not_to equal(Permittable::JsonSchema::SCALAR_SCHEMAS[:decimal]["type"]) + + items_type << "null" + + expect(Permittable::JsonSchema::SCALAR_SCHEMAS[:decimal]["type"]).to eq(%w[string number]) + expect(property("ds2") { array :ds2, of: :decimal }["items"]["type"]).to eq(%w[string number]) + ensure + Permittable::JsonSchema::SCALAR_SCHEMAS[:decimal]["type"].delete("null") + end end describe "root:" do From fe66e6c41c4d2524d4a0418f6cfa96d861b0ddda Mon Sep 17 00:00:00 2001 From: Sang Date: Wed, 30 Sep 2026 00:30:58 +0700 Subject: [PATCH 2/2] Correct the scalar_schema comment: nullify! rebinds, it does not append nullify! builds a new Array (Array(type) + ["null"]), so nothing in this file ever mutated the shared SCALAR_SCHEMAS entry in place. The deep_dup guards against a caller mutating the exported document it owns, which is what the comment now says. Co-Authored-By: Claude Fable 5.1 --- lib/permittable/json_schema.rb | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/permittable/json_schema.rb b/lib/permittable/json_schema.rb index a0a5c9d..7bba457 100644 --- a/lib/permittable/json_schema.rb +++ b/lib/permittable/json_schema.rb @@ -114,12 +114,14 @@ def nullify!(schema, field) schema end - # deep_dup, not dup: SCALAR_SCHEMAS entries like :decimal nest a live Array - # ("type" => %w[string number]) inside the frozen top-level Hash. .freeze - # is shallow, so a shallow .dup here would hand every :decimal field's - # exported schema the SAME Array — nullify! appending "null" to one - # field's "type" would then mutate the shared constant, misdocumenting - # every :decimal field exported afterward in the process as nullable. + # deep_dup, not dup: .freeze is shallow, so the Array nested in an entry + # like :decimal ("type" => %w[string number]) stays live inside the frozen + # top-level Hash, and a shallow .dup would hand every :decimal field's + # exported schema that SAME Array. Nothing in this file mutates it in + # place (nullify! rebinds "type" to a new Array), but the exported + # document is caller-owned data, and a caller appending to it would + # otherwise silently rewrite the constant for every schema exported + # afterward in the process. def scalar_schema(field) schema = SCALAR_SCHEMAS.fetch(field[:type]).deep_dup apply_format_name!(schema, field)