Skip to content

Commit 4aafd06

Browse files
authored
fix: Deep-duplicated event attributes in to_h to avoid returning frozen objects (#44)
Signed-off-by: Daniel Azuma <dazuma@google.com>
1 parent 8f0ac6b commit 4aafd06

File tree

6 files changed

+131
-52
lines changed

6 files changed

+131
-52
lines changed
Lines changed: 22 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
# frozen_string_literal: true
22

3+
require "cloud_events/event/utils"
4+
35
module CloudEvents
46
module Event
57
##
68
# A helper that extracts and interprets event fields from an input hash.
7-
#
89
# @private
910
#
1011
class FieldInterpreter
1112
def initialize args
12-
@args = keys_to_strings args
13+
@args = Utils.keys_to_strings args
1314
@attributes = {}
1415
end
1516

@@ -26,6 +27,7 @@ def string keys, required: false
2627
case value
2728
when ::String
2829
raise AttributeError, "The #{keys.first} field cannot be empty" if value.empty?
30+
value.freeze
2931
[value, value]
3032
else
3133
raise AttributeError, "Illegal type for #{keys.first}:" \
@@ -40,12 +42,12 @@ def uri keys, required: false
4042
when ::String
4143
raise AttributeError, "The #{keys.first} field cannot be empty" if value.empty?
4244
begin
43-
[::URI.parse(value), value]
45+
[Utils.deep_freeze(::URI.parse(value)), value.freeze]
4446
rescue ::URI::InvalidURIError => e
4547
raise AttributeError, "Illegal format for #{keys.first}: #{e.message}"
4648
end
4749
when ::URI::Generic
48-
[value, value.to_s]
50+
[Utils.deep_freeze(value), value.to_s.freeze]
4951
else
5052
raise AttributeError, "Illegal type for #{keys.first}:" \
5153
" String or URI expected but #{value.class} found"
@@ -58,15 +60,15 @@ def rfc3339_date_time keys, required: false
5860
case value
5961
when ::String
6062
begin
61-
[::DateTime.rfc3339(value), value]
63+
[Utils.deep_freeze(::DateTime.rfc3339(value)), value.freeze]
6264
rescue ::Date::Error => e
6365
raise AttributeError, "Illegal format for #{keys.first}: #{e.message}"
6466
end
6567
when ::DateTime
66-
[value, value.rfc3339]
68+
[Utils.deep_freeze(value), value.rfc3339.freeze]
6769
when ::Time
6870
value = value.to_datetime
69-
[value, value.rfc3339]
71+
[Utils.deep_freeze(value), value.rfc3339.freeze]
7072
else
7173
raise AttributeError, "Illegal type for #{keys.first}:" \
7274
" String, Time, or DateTime expected but #{value.class} found"
@@ -79,7 +81,7 @@ def content_type keys, required: false
7981
case value
8082
when ::String
8183
raise AttributeError, "The #{keys.first} field cannot be empty" if value.empty?
82-
[ContentType.new(value), value]
84+
[ContentType.new(value), value.freeze]
8385
when ContentType
8486
[value, value.to_s]
8587
else
@@ -94,6 +96,7 @@ def spec_version keys, accept:
9496
case value
9597
when ::String
9698
raise SpecVersionError, "Unrecognized specversion: #{value}" unless accept =~ value
99+
value.freeze
97100
[value, value]
98101
else
99102
raise AttributeError, "Illegal type for #{keys.first}:" \
@@ -102,8 +105,17 @@ def spec_version keys, accept:
102105
end
103106
end
104107

108+
def data_object keys, required: false
109+
object keys, required: required, allow_nil: true do |value|
110+
Utils.deep_freeze value
111+
[value, value]
112+
end
113+
end
114+
105115
UNDEFINED = ::Object.new.freeze
106116

117+
private
118+
107119
def object keys, required: false, allow_nil: false
108120
value = UNDEFINED
109121
keys.each do |key|
@@ -115,44 +127,10 @@ def object keys, required: false, allow_nil: false
115127
raise AttributeError, "The #{keys.first} field is required" if required
116128
return nil
117129
end
118-
if block_given?
119-
converted, raw = yield value
120-
deep_freeze converted
121-
else
122-
converted = raw = value
123-
end
124-
@attributes[keys.first.freeze] = raw.freeze
130+
converted, raw = yield value
131+
@attributes[keys.first.freeze] = raw
125132
converted
126133
end
127-
128-
private
129-
130-
def deep_freeze obj
131-
case obj
132-
when ::Hash
133-
obj.each do |key, val|
134-
deep_freeze key
135-
deep_freeze val
136-
end
137-
when ::Array
138-
obj.each do |val|
139-
deep_freeze val
140-
end
141-
else
142-
obj.instance_variables.each do |iv|
143-
deep_freeze obj.instance_variable_get iv
144-
end
145-
end
146-
obj.freeze
147-
end
148-
149-
def keys_to_strings hash
150-
result = {}
151-
hash.each do |key, val|
152-
result[key.to_s] = val
153-
end
154-
result
155-
end
156134
end
157135
end
158136
end

lib/cloud_events/event/utils.rb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# frozen_string_literal: true
2+
3+
module CloudEvents
4+
module Event
5+
##
6+
# A variety of helper methods.
7+
# @private
8+
#
9+
module Utils
10+
class << self
11+
def deep_freeze obj
12+
case obj
13+
when ::Hash
14+
obj.each do |key, val|
15+
deep_freeze key
16+
deep_freeze val
17+
end
18+
when ::Array
19+
obj.each do |val|
20+
deep_freeze val
21+
end
22+
else
23+
obj.instance_variables.each do |iv|
24+
deep_freeze obj.instance_variable_get iv
25+
end
26+
end
27+
obj.freeze
28+
end
29+
30+
def deep_dup obj
31+
case obj
32+
when ::Hash
33+
obj.each_with_object({}) { |(key, val), hash| hash[deep_dup key] = deep_dup val }
34+
when ::Array
35+
obj.map { |val| deep_dup val }
36+
else
37+
obj.dup
38+
end
39+
end
40+
41+
def keys_to_strings hash
42+
result = {}
43+
hash.each do |key, val|
44+
result[key.to_s] = val
45+
end
46+
result
47+
end
48+
end
49+
end
50+
end
51+
end

lib/cloud_events/event/v0.rb

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
require "date"
44
require "uri"
55

6+
require "cloud_events/event/field_interpreter"
7+
require "cloud_events/event/utils"
8+
69
module CloudEvents
710
module Event
811
##
@@ -43,7 +46,7 @@ class V0
4346
# field.
4447
# * **:type** [`String`] - _required_ - The event `type` field.
4548
# * **:data** [`Object`] - _optional_ - The data associated with the
46-
# event (i.e. the `data` field.)
49+
# event (i.e. the `data` field).
4750
# * **:data_content_encoding** (or **:datacontentencoding**)
4851
# [`String`] - _optional_ - The content-encoding for the data (i.e.
4952
# the `datacontentencoding` field.)
@@ -74,7 +77,7 @@ def initialize attributes: nil, **args
7477
@id = interpreter.string ["id"], required: true
7578
@source = interpreter.uri ["source"], required: true
7679
@type = interpreter.string ["type"], required: true
77-
@data = interpreter.object ["data"], allow_nil: true
80+
@data = interpreter.data_object ["data"]
7881
@data_content_encoding = interpreter.string ["datacontentencoding", "data_content_encoding"]
7982
@data_content_type = interpreter.content_type ["datacontenttype", "data_content_type"]
8083
@schema_url = interpreter.uri ["schemaurl", "schema_url"]
@@ -112,6 +115,8 @@ def with **changes
112115
# event["time"] # => String rfc3339 representation
113116
# event.time # => DateTime object
114117
#
118+
# Results are also always frozen and cannot be modified in place.
119+
#
115120
# @param key [String,Symbol] The attribute name.
116121
# @return [String,nil]
117122
#
@@ -121,12 +126,12 @@ def [] key
121126

122127
##
123128
# Return a hash representation of this event. The returned hash is an
124-
# unfrozen copy. Modifications do not affect the original event.
129+
# unfrozen deep copy. Modifications do not affect the original event.
125130
#
126131
# @return [Hash]
127132
#
128133
def to_h
129-
@attributes.dup
134+
Utils.deep_dup @attributes
130135
end
131136

132137
##

lib/cloud_events/event/v1.rb

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
require "date"
44
require "uri"
55

6+
require "cloud_events/event/field_interpreter"
7+
require "cloud_events/event/utils"
8+
69
module CloudEvents
710
module Event
811
##
@@ -43,7 +46,7 @@ class V1
4346
# field.
4447
# * **:type** [`String`] - _required_ - The event `type` field.
4548
# * **:data** [`Object`] - _optional_ - The data associated with the
46-
# event (i.e. the `data` field.)
49+
# event (i.e. the `data` field).
4750
# * **:data_content_type** (or **:datacontenttype**) [`String`,
4851
# {ContentType}] - _optional_ - The content-type for the data, if
4952
# the data is a string (i.e. the event `datacontenttype` field.)
@@ -71,7 +74,7 @@ def initialize attributes: nil, **args
7174
@id = interpreter.string ["id"], required: true
7275
@source = interpreter.uri ["source"], required: true
7376
@type = interpreter.string ["type"], required: true
74-
@data = interpreter.object ["data"], allow_nil: true
77+
@data = interpreter.data_object ["data"]
7578
@data_content_type = interpreter.content_type ["datacontenttype", "data_content_type"]
7679
@data_schema = interpreter.uri ["dataschema", "data_schema"]
7780
@subject = interpreter.string ["subject"]
@@ -108,6 +111,8 @@ def with **changes
108111
# event["time"] # => String rfc3339 representation
109112
# event.time # => DateTime object
110113
#
114+
# Results are also always frozen and cannot be modified in place.
115+
#
111116
# @param key [String,Symbol] The attribute name.
112117
# @return [String,nil]
113118
#
@@ -117,12 +122,12 @@ def [] key
117122

118123
##
119124
# Return a hash representation of this event. The returned hash is an
120-
# unfrozen copy. Modifications do not affect the original event.
125+
# unfrozen deep copy. Modifications do not affect the original event.
121126
#
122127
# @return [Hash]
123128
#
124129
def to_h
125-
@attributes.dup
130+
Utils.deep_dup @attributes
126131
end
127132

128133
##

test/event/test_v0.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,4 +240,24 @@
240240
assert_nil event[:traceparent]
241241
refute_includes event.to_h, "traceparent"
242242
end
243+
244+
it "returns a deep copy from to_h" do
245+
my_data = { "a" => [1, 2, 3, 4] }
246+
event = CloudEvents::Event::V0.new id: my_id,
247+
source: my_source_string,
248+
type: my_type,
249+
spec_version: spec_version,
250+
data: my_data
251+
assert Ractor.shareable? event if defined? Ractor
252+
253+
data_from_getter = event.data
254+
assert_equal my_data, data_from_getter
255+
assert data_from_getter.frozen?
256+
assert data_from_getter["a"].frozen?
257+
258+
data_from_hash = event.to_h["data"]
259+
assert_equal my_data, data_from_hash
260+
refute data_from_hash.frozen?
261+
refute data_from_hash["a"].frozen?
262+
end
243263
end

test/event/test_v1.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,4 +231,24 @@
231231
assert_nil event[:traceparent]
232232
refute_includes event.to_h, "traceparent"
233233
end
234+
235+
it "returns a deep copy from to_h" do
236+
my_data = { "a" => [1, 2, 3, 4] }
237+
event = CloudEvents::Event::V1.new id: my_id,
238+
source: my_source_string,
239+
type: my_type,
240+
spec_version: spec_version,
241+
data: my_data
242+
assert Ractor.shareable? event if defined? Ractor
243+
244+
data_from_getter = event.data
245+
assert_equal my_data, data_from_getter
246+
assert data_from_getter.frozen?
247+
assert data_from_getter["a"].frozen?
248+
249+
data_from_hash = event.to_h["data"]
250+
assert_equal my_data, data_from_hash
251+
refute data_from_hash.frozen?
252+
refute data_from_hash["a"].frozen?
253+
end
234254
end

0 commit comments

Comments
 (0)