studio-engine 0.98.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/README.md +10 -0
- data/app/controllers/studio/knowledge_docs_controller.rb +24 -1
- data/app/javascript/studio/controllers/modal_host_controller.js +6 -16
- data/app/javascript/studio/modal_host.js +25 -5
- data/app/models/studio/knowledge_doc.rb +37 -2
- data/app/views/studio/knowledge_docs/_preview.html.erb +66 -0
- data/app/views/studio/knowledge_docs/_preview_fallback.html.erb +9 -0
- data/app/views/studio/knowledge_docs/_preview_sheet.html.erb +43 -0
- data/app/views/studio/knowledge_docs/_preview_styles.html.erb +24 -0
- data/app/views/studio/knowledge_docs/preview.html.erb +12 -0
- data/app/views/studio/knowledge_docs/show.html.erb +26 -2
- data/lib/studio/knowledge_preview/number_format.rb +342 -0
- data/lib/studio/knowledge_preview/xlsx.rb +646 -0
- data/lib/studio/knowledge_preview/xml_guard.rb +344 -0
- data/lib/studio/knowledge_preview/zip.rb +242 -0
- data/lib/studio/knowledge_preview.rb +322 -0
- data/lib/studio/s3.rb +39 -4
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +5 -2
- data/studio-engine.gemspec +4 -0
- metadata +26 -2
|
@@ -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
|