diff --git a/lib/permittable/json_schema.rb b/lib/permittable/json_schema.rb index 0d29678..7bba457 100644 --- a/lib/permittable/json_schema.rb +++ b/lib/permittable/json_schema.rb @@ -114,8 +114,16 @@ def nullify!(schema, field) schema end + # 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]).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 +159,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