studio-engine 0.98.0.rc1 → 0.99.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ module KnowledgePreview
5
+ # A byte filter between the inflater and the XML parser.
6
+ #
7
+ # The reader's other bounds (inflate budget, deadline, parsed-text budget)
8
+ # all sit in Ruby, BETWEEN XML nodes. The parser, libxml, does its own
9
+ # allocating INSIDE one read, before any node comes out, and for some
10
+ # input it spends fifteen to forty times the bytes it was given: a
11
+ # megabyte of ATTLIST or ENTITY declarations, a start tag with a million
12
+ # attributes, or sixty start tags of a megabyte each left open inside one
13
+ # another, costs it a gigabyte or more that no Ruby-side bound ever sees.
14
+ # So this sits BELOW the parser and never hands it such input.
15
+ #
16
+ # It is a tokenizer of the least XML that needs telling apart, and no
17
+ # more: text, a tag (with its quoted attribute values), and the three
18
+ # constructs that may hold "<" freely (comment, CDATA section, processing
19
+ # instruction). On that it enforces:
20
+ #
21
+ # 1. UTF-8 markup from the first byte. The part must open with "<", or
22
+ # a UTF-8 byte-order mark and then "<", and hold no NUL byte. With
23
+ # the parser told the bytes are UTF-8 (Xlsx#each_node passes the
24
+ # encoding, which makes libxml ignore any encoding the part declares
25
+ # for itself), the bytes scanned here are the characters parsed
26
+ # there, so a UTF-16 part cannot hide from the scan.
27
+ # 2. No document type declaration. A DTD is where ATTLIST and ENTITY
28
+ # live; no workbook has one, and libxml has no option to forbid it.
29
+ # The match is exact-case because libxml's is: "<!doctype" is a
30
+ # syntax error there, not a DTD.
31
+ # 3. MAX_TOKEN_BYTES for a text node or a CDATA section, which is where
32
+ # a cell's text lives, and MAX_ASIDE_BYTES for ALL the comments and
33
+ # processing instructions in a part together: no workbook needs
34
+ # them at any length, and the parser keeps each one it meets under
35
+ # an open element until that element ends.
36
+ # 4. MAX_OPEN_TAG_BYTES for start tags, counted across every element
37
+ # that is OPEN at once, the one being read included. The parser keeps
38
+ # an open element's attributes alive until its end tag, so bounding
39
+ # each tag alone still lets nesting multiply it; this bounds what is
40
+ # alive. A workbook's elements nest eight deep in tags of a hundred
41
+ # bytes.
42
+ # 5. MAX_DEPTH open elements and MAX_NAMESPACE_DECLARATIONS in a part.
43
+ # The parser interns every namespace prefix for the life of the
44
+ # parse, so small tags each declaring a new one grow it without any
45
+ # single token being large.
46
+ #
47
+ # It answers IO#read for the parser. A refusal ends the stream (the parser
48
+ # is handed nothing of the chunk that crossed the line) and is kept as
49
+ # `failure`, because an exception raised inside the parser's read callback
50
+ # does not reliably come out as itself; the caller raises it afterwards.
51
+ class XmlGuard
52
+ # Excel's longest cell is 32,767 characters. At four UTF-8 bytes each
53
+ # that is 131 KB, and written as numeric character references about
54
+ # 330 KB, so a megabyte is three times the longest text a real part
55
+ # holds.
56
+ MAX_TOKEN_BYTES = 1_048_576
57
+ # Comments and processing instructions, summed over the part. Workbook
58
+ # writers emit the XML declaration and nothing else of the kind. The
59
+ # parser keeps the ones under an open element alive, so a cap on each
60
+ # would still let a thousand small ones add up.
61
+ MAX_ASIDE_BYTES = 65_536
62
+ # The start tags a workbook writer produces are tens to hundreds of
63
+ # bytes; the root's namespace declarations make the longest, a kilobyte
64
+ # or two. This allows thirty times that, summed over open elements.
65
+ MAX_OPEN_TAG_BYTES = 65_536
66
+ # libxml's own nesting limit; the stack here never outgrows it.
67
+ MAX_DEPTH = 256
68
+ # A real part declares its namespaces once, on the root: a few dozen.
69
+ MAX_NAMESPACE_DECLARATIONS = 1_024
70
+ # The parser may ask for any amount; it is given at most this much at a
71
+ # time, so nothing reaches it more than one chunk ahead of the scan.
72
+ CHUNK_BYTES = 16_384
73
+
74
+ BOM = "\xEF\xBB\xBF".b
75
+ NUL = "\x00".b
76
+ OPEN = "<".b
77
+ DOCTYPE = "<!DOCTYPE".b
78
+ NAMESPACE = "xmlns".b
79
+ # opener => [terminator, which limit], for the constructs that may
80
+ # contain "<".
81
+ OPAQUE = { "<!--".b => ["-->".b, :aside], "<![CDATA[".b => ["]]>".b, :token], "<?".b => ["?>".b, :aside] }.freeze
82
+ # The longest opener; a "<" this close to the end of what has arrived
83
+ # waits for the next chunk before it is classified.
84
+ LOOKAHEAD = 9
85
+ TAG_STOP = /["'>]/n
86
+ SLASH = 47
87
+ CLOSE = 62
88
+ BANG = 33
89
+ QUERY = 63
90
+ EMPTY = "".b.freeze
91
+
92
+ attr_reader :failure
93
+
94
+ def initialize(io, max_token_bytes: MAX_TOKEN_BYTES, max_aside_bytes: MAX_ASIDE_BYTES,
95
+ max_open_tag_bytes: MAX_OPEN_TAG_BYTES,
96
+ max_namespace_declarations: MAX_NAMESPACE_DECLARATIONS, chunk_bytes: CHUNK_BYTES)
97
+ @io = io
98
+ @max_token = max_token_bytes
99
+ @max_aside = max_aside_bytes
100
+ @limit = max_token_bytes # the limit the current token is held to
101
+ @aside = false # the current token is a comment or instruction
102
+ @aside_bytes = 0 # comments and instructions seen so far, in all
103
+ @max_open = max_open_tag_bytes
104
+ @max_namespaces = max_namespace_declarations
105
+ @chunk_bytes = chunk_bytes
106
+ @opened = false
107
+ @pending = +"".b # read from the source, not yet handed to the parser
108
+ @carry = +"".b # handed over already, not yet classified by the scan
109
+ @state = :text # :text, :tag or :opaque
110
+ @token = 0 # bytes of the current text or opaque token
111
+ @terminator = nil # what ends the current opaque token
112
+ @tag = 0 # bytes of the tag being read
113
+ @quote = nil # the quote character an attribute value is inside
114
+ @closing = false # the tag being read is an end tag
115
+ @last = nil # the last byte of the tag read so far
116
+ @open = [] # the size of each open element's start tag
117
+ @open_bytes = 0
118
+ @namespaces = 0
119
+ @namespace_tail = EMPTY
120
+ end
121
+
122
+ def read(length = nil, _outbuf = nil)
123
+ return nil if @failure
124
+
125
+ open_part unless @opened
126
+ return nil if @failure
127
+
128
+ chunk = @pending.empty? ? @io.read(wanted(length)) : @pending.slice!(0, wanted(length))
129
+ return nil if chunk.nil? || chunk.empty?
130
+
131
+ chunk = chunk.b
132
+ scan(chunk)
133
+ @failure ? nil : chunk
134
+ end
135
+
136
+ private
137
+
138
+ def wanted(length)
139
+ length.nil? ? @chunk_bytes : [length, @chunk_bytes].min
140
+ end
141
+
142
+ def refuse(error)
143
+ @failure = error
144
+ @pending = +"".b
145
+ nil
146
+ end
147
+
148
+ def too_large
149
+ refuse(TooLarge.new("its XML holds more in one place than a preview reads"))
150
+ end
151
+
152
+ # Gathers the first four bytes before anything is handed over: a byte
153
+ # order mark is three, and what follows it decides.
154
+ def open_part
155
+ @opened = true
156
+ while @pending.bytesize < 4
157
+ more = @io.read(@chunk_bytes)
158
+ break if more.nil? || more.empty?
159
+
160
+ @pending << more.b
161
+ end
162
+ start = @pending.start_with?(BOM) ? @pending.byteslice(3, 1) : @pending.byteslice(0, 1)
163
+ refuse(Unreadable.new("its XML is not UTF-8")) unless start == OPEN || @pending.empty?
164
+ end
165
+
166
+ def scan(chunk)
167
+ return refuse(Unreadable.new("its XML is not UTF-8")) if chunk.include?(NUL)
168
+
169
+ data = @carry + chunk
170
+ position = 0
171
+ while position < data.bytesize
172
+ position =
173
+ case @state
174
+ when :text then text(data, position)
175
+ when :tag then tag(data, position)
176
+ else opaque(data, position)
177
+ end
178
+ return if @failure || position.nil?
179
+ end
180
+ @carry = +"".b
181
+ end
182
+
183
+ # --- text ---------------------------------------------------------------------
184
+
185
+ def text(data, position)
186
+ found = data.index(OPEN, position)
187
+ if found.nil?
188
+ grow(data.bytesize - position)
189
+ return data.bytesize
190
+ end
191
+
192
+ grow(found - position)
193
+ return nil if @failure
194
+
195
+ # By far the commonest case, so it is decided on one byte and
196
+ # allocates nothing: a "<" not followed by "!" or "?" is a tag.
197
+ following = data.getbyte(found + 1)
198
+ return hold(data, found) if following.nil?
199
+ return start_tag(found, following) unless following == BANG || following == QUERY
200
+ # Not enough bytes yet to tell "<!--" from "<!DOCTYPE" from "<![CDATA[".
201
+ return hold(data, found) if data.bytesize - found < LOOKAHEAD
202
+
203
+ if data.byteslice(found, DOCTYPE.bytesize) == DOCTYPE
204
+ return refuse(Unreadable.new("it carries a document type declaration, which no workbook has"))
205
+ end
206
+
207
+ opener, (terminator, kind) = OPAQUE.find { |open, _| data.byteslice(found, open.bytesize) == open }
208
+ # "<!" followed by anything else is not XML the parser will accept;
209
+ # it is passed on as a tag for the parser to refuse.
210
+ return start_tag(found, following) unless opener
211
+
212
+ @token = 0
213
+ @state = :opaque
214
+ @terminator = terminator
215
+ @aside = kind == :aside
216
+ # An aside starts where the last one stopped: its limit is a total.
217
+ @token = @aside_bytes if @aside
218
+ @limit = @aside ? @max_aside : @max_token
219
+ grow(opener.bytesize)
220
+ @failure ? nil : found + opener.bytesize
221
+ end
222
+
223
+ def start_tag(found, following)
224
+ @token = 0
225
+ @state = :tag
226
+ @tag = 0
227
+ @quote = nil
228
+ @namespace_tail = EMPTY
229
+ @closing = following == SLASH
230
+ @last = nil
231
+ spend_tag(1)
232
+ @failure ? nil : found + 1
233
+ end
234
+
235
+ def grow(bytes)
236
+ @token += bytes
237
+ too_large if @token > @limit
238
+ end
239
+
240
+ # --- tags ---------------------------------------------------------------------
241
+
242
+ # Reads to the end of a quoted value, or to the next quote or the ">"
243
+ # that ends the tag. A ">" inside a quoted value is only a character.
244
+ def tag(data, position)
245
+ if @quote
246
+ found = data.index(@quote, position)
247
+ return leave_tag(data, position, data.bytesize) if found.nil?
248
+
249
+ @quote = nil
250
+ return leave_tag(data, position, found + 1)
251
+ end
252
+
253
+ found = data.index(TAG_STOP, position)
254
+ finish = found || data.bytesize
255
+ # A stretch too short to hold "xmlns" (most of them: "<c r=", "<t>")
256
+ # is not looked at, unless an earlier stretch left a tail to join.
257
+ if finish - position >= NAMESPACE.bytesize || !@namespace_tail.empty? || found.nil?
258
+ count_namespaces(data.byteslice(position, finish - position), continues: found.nil?)
259
+ return nil if @failure
260
+ end
261
+ return leave_tag(data, position, data.bytesize) if found.nil?
262
+
263
+ if data.getbyte(found) == CLOSE
264
+ before = found > position ? data.getbyte(found - 1) : @last
265
+ after = leave_tag(data, position, found + 1)
266
+ return nil if @failure
267
+
268
+ end_tag(before == SLASH)
269
+ return @failure ? nil : after
270
+ end
271
+
272
+ @quote = data.byteslice(found, 1)
273
+ leave_tag(data, position, found + 1)
274
+ end
275
+
276
+ def leave_tag(data, from, to)
277
+ spend_tag(to - from)
278
+ return nil if @failure
279
+
280
+ @last = data.getbyte(to - 1) if to > from
281
+ to
282
+ end
283
+
284
+ # The tag being read counts against the open-tag budget as it grows, so
285
+ # a tag that would overspend it is refused before the parser has it all.
286
+ def spend_tag(bytes)
287
+ @tag += bytes
288
+ too_large if @open_bytes + @tag > @max_open
289
+ end
290
+
291
+ # Counts "xmlns" in a stretch of a tag that is outside any quoted
292
+ # value. When the stretch runs to the end of what has arrived, its last
293
+ # four bytes are kept, so a declaration split across chunks is counted.
294
+ def count_namespaces(segment, continues:)
295
+ text = @namespace_tail + segment.to_s
296
+ @namespaces += text.scan(NAMESPACE).size
297
+ @namespace_tail = continues ? (text.byteslice(-(NAMESPACE.bytesize - 1)..) || text) : EMPTY
298
+ too_large if @namespaces > @max_namespaces
299
+ end
300
+
301
+ def end_tag(self_closing)
302
+ if @closing
303
+ @open_bytes -= @open.pop || 0
304
+ elsif !self_closing
305
+ @open << @tag
306
+ @open_bytes += @tag
307
+ too_large if @open.size > MAX_DEPTH
308
+ end
309
+ @state = :text
310
+ @token = 0
311
+ end
312
+
313
+ # --- comments, CDATA sections, processing instructions ------------------------
314
+
315
+ def opaque(data, position)
316
+ found = data.index(@terminator, position)
317
+ if found.nil?
318
+ # The terminator may be split across chunks: its last bytes wait.
319
+ keep = [@terminator.bytesize - 1, data.bytesize - position].min
320
+ grow(data.bytesize - position - keep)
321
+ return @failure ? nil : hold(data, data.bytesize - keep)
322
+ end
323
+
324
+ grow(found + @terminator.bytesize - position)
325
+ return nil if @failure
326
+
327
+ @aside_bytes = @token if @aside
328
+ @aside = false
329
+ @state = :text
330
+ @limit = @max_token
331
+ @token = 0
332
+ found + @terminator.bytesize
333
+ end
334
+
335
+ # Leaves the tail of `data` for the next chunk to finish, and stops the
336
+ # scan. Those bytes were handed to the parser already; a few bytes of an
337
+ # unfinished opener are nothing it can act on.
338
+ def hold(data, from)
339
+ @carry = data.byteslice(from, data.bytesize - from) || +"".b
340
+ nil
341
+ end
342
+ end
343
+ end
344
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "zlib"
5
+ require "stringio"
6
+
7
+ module Studio
8
+ module KnowledgePreview
9
+ # The least of a zip reader an .xlsx needs: the central directory, and each
10
+ # entry as a STREAM that inflates on demand.
11
+ #
12
+ # Written on Zlib (stdlib) rather than on rubyzip for two reasons. The
13
+ # consumers lock different rubyzip majors (2.4 and 3.x), and none of them
14
+ # carries it outside the test group, so a gemspec dependency would put a
15
+ # new runtime gem in every app for one admin page. And a streaming reader
16
+ # is the control a preview wants: an entry is inflated only as far as the
17
+ # caller reads it, and every byte any entry yields is spent from ONE
18
+ # budget for the whole archive (`inflate_budget`). A small file that
19
+ # declares a huge sheet, lies about its sizes, or points twenty sheets at
20
+ # one part costs what was read, in total, and never more than the budget.
21
+ #
22
+ # Deliberately unsupported, each refused with Unreadable so the page falls
23
+ # back to the download link: zip64, encrypted entries, and any compression
24
+ # other than stored (0) and deflate (8). Sizes and offsets come from the
25
+ # central directory, so entries written with a trailing data descriptor
26
+ # read correctly.
27
+ class Zip
28
+ EOCD_SIGNATURE = "PK\x05\x06".b
29
+ CENTRAL_SIGNATURE = "PK\x01\x02".b
30
+ LOCAL_SIGNATURE = "PK\x03\x04".b
31
+ EOCD_MIN_SIZE = 22
32
+ # An end-of-central-directory record may be followed by a comment of up
33
+ # to 65,535 bytes; it is never further from the end than this.
34
+ EOCD_SEARCH_WINDOW = EOCD_MIN_SIZE + 0xFFFF
35
+ MAX_ENTRIES = 5_000
36
+ ZIP64_MARKER = 0xFFFFFFFF
37
+
38
+ Entry = Struct.new(:name, :flags, :compression, :compressed_size, :local_offset)
39
+
40
+ # What the whole archive may still expand to. Shared by every entry
41
+ # opened, so reading one part twenty times spends twenty times.
42
+ class Budget
43
+ def initialize(bytes)
44
+ @limit = bytes
45
+ @left = bytes
46
+ end
47
+
48
+ def spend(bytes)
49
+ @left -= bytes
50
+ raise TooLarge, "it expands past the #{megabytes} MB preview limit" if @left.negative?
51
+ end
52
+
53
+ private
54
+
55
+ def megabytes
56
+ (@limit / 1_048_576.0).round(1).to_s.delete_suffix(".0")
57
+ end
58
+ end
59
+
60
+ # Reads a deflate stream a kilobyte of compressed input at a time. The
61
+ # small step keeps the buffer small: deflate tops out near 1000:1, so
62
+ # one step can add about a megabyte to it and no more. What bounds the
63
+ # total is the archive's Budget.
64
+ #
65
+ # `failure` exists because the consumer is an XML parser reading through
66
+ # a C callback: an exception raised inside #read does not reliably come
67
+ # out the other side as itself. So a failed read ends the stream and
68
+ # keeps the reason, and Zip#open raises it once the parser has let go.
69
+ class InflateIO
70
+ STEP = 1024
71
+
72
+ attr_reader :failure
73
+
74
+ def initialize(compressed, budget)
75
+ @compressed = compressed
76
+ @position = 0
77
+ @budget = budget
78
+ @buffer = +"".b
79
+ @offset = 0
80
+ @inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
81
+ @finished = false
82
+ end
83
+
84
+ # IO#read's contract as Nokogiri's reader uses it: up to `length`
85
+ # bytes, and nil once the stream is exhausted.
86
+ def read(length = nil, _outbuf = nil)
87
+ begin
88
+ fill while !@finished && (length.nil? || available < length)
89
+ rescue Error => e
90
+ fail_with(e)
91
+ rescue Zlib::Error
92
+ fail_with(Unreadable.new("its contents are damaged"))
93
+ end
94
+ return nil if available.zero? && !length.nil?
95
+
96
+ # Read by offset, not by cutting the front off the buffer: a highly
97
+ # compressed part can leave megabytes buffered, and the parser asks
98
+ # for a few kilobytes at a time.
99
+ taken = @buffer.byteslice(@offset, length || available)
100
+ @offset += taken.bytesize
101
+ taken
102
+ end
103
+
104
+ def close
105
+ @inflater.close unless @inflater.closed?
106
+ end
107
+
108
+ private
109
+
110
+ def available
111
+ @buffer.bytesize - @offset
112
+ end
113
+
114
+ def fail_with(error)
115
+ @failure = error
116
+ @finished = true
117
+ @buffer = +"".b
118
+ @offset = 0
119
+ end
120
+
121
+ def fill
122
+ chunk = @compressed.byteslice(@position, STEP)
123
+ if chunk.nil? || chunk.empty?
124
+ @finished = true
125
+ return
126
+ end
127
+
128
+ @position += chunk.bytesize
129
+ out = @inflater.inflate(chunk)
130
+ @budget.spend(out.bytesize)
131
+
132
+ if available.zero?
133
+ @buffer = out
134
+ @offset = 0
135
+ else
136
+ @buffer << out
137
+ end
138
+ @finished = true if @inflater.finished?
139
+ end
140
+ end
141
+
142
+ def initialize(bytes, inflate_budget:)
143
+ @bytes = bytes.b
144
+ @budget = Budget.new(inflate_budget)
145
+ @entries = read_central_directory
146
+ end
147
+
148
+ def names
149
+ @entries.keys
150
+ end
151
+
152
+ def entry?(name)
153
+ @entries.key?(normalize(name))
154
+ end
155
+
156
+ # Yields an IO over the entry's uncompressed bytes, or returns nil when
157
+ # the archive has no such entry.
158
+ def open(name)
159
+ entry = @entries[normalize(name)]
160
+ return nil unless entry
161
+
162
+ io = io_for(entry)
163
+ begin
164
+ result = yield io
165
+ rescue StandardError
166
+ # Whatever the reader made of a stream that ended early, the reason
167
+ # it ended is the truer error.
168
+ raise failure_of(io) || $ERROR_INFO
169
+ ensure
170
+ io.close
171
+ end
172
+ raise failure_of(io) if failure_of(io)
173
+
174
+ result
175
+ end
176
+
177
+ private
178
+
179
+ def failure_of(io)
180
+ io.failure if io.respond_to?(:failure)
181
+ end
182
+
183
+ def normalize(name)
184
+ name.to_s.delete_prefix("/")
185
+ end
186
+
187
+ def read_central_directory
188
+ window_start = [@bytes.bytesize - EOCD_SEARCH_WINDOW, 0].max
189
+ eocd = @bytes.rindex(EOCD_SIGNATURE)
190
+ raise Unreadable, "it is not a zip archive" if eocd.nil? || eocd < window_start
191
+ raise Unreadable, "its zip directory is cut short" if eocd + EOCD_MIN_SIZE > @bytes.bytesize
192
+
193
+ count, _size, offset = @bytes.byteslice(eocd + 10, 10).unpack("vVV")
194
+ raise Unreadable, "it is a zip64 archive" if offset == ZIP64_MARKER || count == 0xFFFF
195
+ raise Unreadable, "it holds too many zip entries" if count > MAX_ENTRIES
196
+
197
+ entries = {}
198
+ position = offset
199
+ count.times do
200
+ header = @bytes.byteslice(position, 46)
201
+ unless header && header.bytesize == 46 && header.start_with?(CENTRAL_SIGNATURE)
202
+ raise Unreadable, "its zip directory is damaged"
203
+ end
204
+
205
+ flags, compression = header.byteslice(8, 4).unpack("vv")
206
+ compressed_size = header.byteslice(20, 4).unpack1("V")
207
+ name_length, extra_length, comment_length = header.byteslice(28, 6).unpack("vvv")
208
+ local_offset = header.byteslice(42, 4).unpack1("V")
209
+ name = @bytes.byteslice(position + 46, name_length).to_s.force_encoding(Encoding::UTF_8)
210
+ raise Unreadable, "it is a zip64 archive" if [compressed_size, local_offset].include?(ZIP64_MARKER)
211
+
212
+ entries[normalize(name)] = Entry.new(name, flags, compression, compressed_size, local_offset)
213
+ position += 46 + name_length + extra_length + comment_length
214
+ end
215
+ entries
216
+ end
217
+
218
+ def io_for(entry)
219
+ raise Unreadable, "it is password-protected" if entry.flags.anybits?(1)
220
+
221
+ local = @bytes.byteslice(entry.local_offset, 30)
222
+ unless local && local.bytesize == 30 && local.start_with?(LOCAL_SIGNATURE)
223
+ raise Unreadable, "its zip entries are damaged"
224
+ end
225
+
226
+ name_length, extra_length = local.byteslice(26, 4).unpack("vv")
227
+ data = @bytes.byteslice(entry.local_offset + 30 + name_length + extra_length, entry.compressed_size)
228
+ raise Unreadable, "its zip entries are cut short" if data.nil? || data.bytesize < entry.compressed_size
229
+
230
+ case entry.compression
231
+ when 0
232
+ # A stored entry is spent whole on opening it: it is already in
233
+ # memory, and opening it again is another copy.
234
+ @budget.spend(data.bytesize)
235
+ StringIO.new(data)
236
+ when 8 then InflateIO.new(data, @budget)
237
+ else raise Unreadable, "it uses a zip compression this preview cannot read"
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end