From 87e20f683363b0e03764598c0879a62c315b29e2 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sun, 20 Sep 2026 18:42:09 +0200 Subject: [PATCH] Replace a registry on registration instead of writing into it A registry is written when a formatter, parser, params builder, validator or versioner class is defined, and from then on it is only ever looked up -- on the request path, at that, once per response, per parsed body or per params build. Have register build the next Hash and freeze it, rather than writing into the one readers hold. Nothing can find a registry changed underneath it, and nothing can write to one without going through register. The spec-only deregister helper replaces the registry in turn. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + lib/grape/util/registry.rb | 11 +++++++++-- spec/grape/util/registry_spec.rb | 13 +++++++++++++ spec/support/deregister.rb | 5 +++-- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63ca9544e..4780d9f4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ * [#2977](https://github.com/ruby-grape/grape/pull/2977): Stop copying the query params of a request whose params builder builds a new object - [@ericproulx](https://github.com/ericproulx). * [#2974](https://github.com/ruby-grape/grape/pull/2974): Run every endpoint's Rack stack through one shared lambda instead of one allocated per endpoint, and make `Grape::Endpoint#run` public - [@ericproulx](https://github.com/ericproulx). * [#2975](https://github.com/ruby-grape/grape/pull/2975): Hold a static `values` or `except_values` collection as it came instead of behind a lambda that only hands it back - [@ericproulx](https://github.com/ericproulx). +* [#2976](https://github.com/ruby-grape/grape/pull/2976): Replace a registry on registration instead of writing into it, so a registered formatter, parser, params builder, validator or versioner cannot be changed underneath a reader - [@ericproulx](https://github.com/ericproulx). * Your contribution here. ### 4.0.1 (2026-09-15) diff --git a/lib/grape/util/registry.rb b/lib/grape/util/registry.rb index be87c04a8..5a025b317 100644 --- a/lib/grape/util/registry.rb +++ b/lib/grape/util/registry.rb @@ -8,7 +8,7 @@ def register(klass) return if short_name.nil? warn "#{short_name} is already registered with class #{registry[short_name]}. It will be overridden globally with the following: #{klass.name}" if registry.key?(short_name) - registry[short_name] = registry[short_name.to_sym] = klass + @registry = registry.merge(short_name => klass, short_name.to_sym => klass).freeze end private @@ -29,8 +29,15 @@ def build_short_name(klass) # # +register+ derives the short name as a String, so the Symbol is the # alias. + # + # Registration replaces the Hash instead of writing into it, and freezes + # the replacement: a registry is written when a formatter, parser, + # params builder, validator or versioner class is defined, and from then + # on it is only ever looked up -- on the request path, at that. Nothing + # holding a registry can find it changed underneath, and nothing can + # write to one without going through +register+. def registry - @registry ||= {} + @registry ||= {}.freeze end end end diff --git a/spec/grape/util/registry_spec.rb b/spec/grape/util/registry_spec.rb index 87e9d8cf2..ec0aa7b27 100644 --- a/spec/grape/util/registry_spec.rb +++ b/spec/grape/util/registry_spec.rb @@ -16,6 +16,10 @@ def registry_empty? def registry_get(key) registry[key] end + + def registry_frozen? + registry.frozen? + end end end @@ -83,6 +87,15 @@ def self.name expect(subject.registry_get(:test_class)).to eq(test_class) expect(subject.registry_get('test_class')).to eq(test_class) end + + it 'hands out a frozen registry before anything is registered' do + expect(subject.registry_frozen?).to be true + end + + it 'hands out a frozen registry after registering' do + subject.register(test_class) + expect(subject.registry_frozen?).to be true + end end context 'with invalid class names' do diff --git a/spec/support/deregister.rb b/spec/support/deregister.rb index 992e64571..fa4a008a5 100644 --- a/spec/support/deregister.rb +++ b/spec/support/deregister.rb @@ -3,8 +3,9 @@ module Deregister # A registration lives under both its String and its Symbol spelling # (see Grape::Util::Registry#register), so undoing one takes both. + # Registration replaces the registry rather than writing into it, and the + # replacement is frozen, so undoing one replaces it in turn. def deregister(key) - registry.delete(key.to_s) - registry.delete(key.to_sym) + @registry = registry.except(key.to_s, key.to_sym).freeze end end