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