claricle 0.1.0 → 0.2.0
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/.rubocop.yml +10 -1
- data/LICENSE +25 -0
- data/README.adoc +439 -157
- data/TODO.plan/00-overview.md +502 -0
- data/TODO.plan/01-core.md +296 -0
- data/TODO.plan/02-inspect.md +163 -0
- data/TODO.plan/03-conform.md +208 -0
- data/TODO.plan/04-convert.md +216 -0
- data/exe/claricle +1 -1
- data/lib/claricle/batch.rb +240 -0
- data/lib/claricle/cli.rb +514 -40
- data/lib/claricle/conformance_options.rb +49 -0
- data/lib/claricle/conversion_engine.rb +21 -0
- data/lib/claricle/convert.rb +21 -0
- data/lib/claricle/detector.rb +696 -0
- data/lib/claricle/emf_header.rb +33 -0
- data/lib/claricle/errors.rb +73 -0
- data/lib/claricle/fault.rb +69 -0
- data/lib/claricle/handlers/base.rb +169 -0
- data/lib/claricle/handlers/extension_points.rb +124 -0
- data/lib/claricle/handlers/metafile.rb +1011 -0
- data/lib/claricle/handlers/pdf.rb +961 -0
- data/lib/claricle/handlers/pdf_arlington/definitions.rb +54 -0
- data/lib/claricle/handlers/pdf_arlington/kind.rb +62 -0
- data/lib/claricle/handlers/pdf_arlington/predicate_gate.rb +39 -0
- data/lib/claricle/handlers/pdf_arlington/rules.rb +86 -0
- data/lib/claricle/handlers/pdf_arlington/walker.rb +131 -0
- data/lib/claricle/handlers/pdf_arlington.rb +38 -0
- data/lib/claricle/handlers/pdf_profiles.rb +66 -0
- data/lib/claricle/handlers/png.rb +1107 -0
- data/lib/claricle/handlers/postscript.rb +1357 -0
- data/lib/claricle/handlers/svg.rb +787 -0
- data/lib/claricle/image.rb +272 -0
- data/lib/claricle/lossiness/levels.rb +13 -0
- data/lib/claricle/lossiness.rb +864 -0
- data/lib/claricle/models/base.rb +460 -0
- data/lib/claricle/models/batch_item.rb +127 -0
- data/lib/claricle/models/conversion.rb +167 -0
- data/lib/claricle/models/format_capability.rb +40 -0
- data/lib/claricle/models/free_form_hash.rb +77 -0
- data/lib/claricle/models/inspection.rb +188 -0
- data/lib/claricle/models/issue.rb +23 -0
- data/lib/claricle/models/location.rb +51 -0
- data/lib/claricle/models/report.rb +54 -0
- data/lib/claricle/models/uncoerced_integer.rb +38 -0
- data/lib/claricle/postscript_fidelity/verdict.rb +37 -0
- data/lib/claricle/postscript_fidelity/view_box.rb +82 -0
- data/lib/claricle/postscript_fidelity.rb +160 -0
- data/lib/claricle/registry.rb +125 -0
- data/lib/claricle/version.rb +1 -1
- data/lib/claricle/writer.rb +468 -0
- data/lib/claricle.rb +244 -7
- metadata +201 -19
- data/claricle.gemspec +0 -42
- data/sig/claricle.rbs +0 -4
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
require_relative "base"
|
|
6
|
+
require_relative "../models/inspection"
|
|
7
|
+
require_relative "../models/issue"
|
|
8
|
+
require_relative "../models/report"
|
|
9
|
+
require_relative "pdf_arlington"
|
|
10
|
+
require_relative "pdf_profiles"
|
|
11
|
+
|
|
12
|
+
module Claricle
|
|
13
|
+
module Handlers
|
|
14
|
+
# pdfrb's line-oriented readers use IO#gets, whose default separator
|
|
15
|
+
# recognises LF but not PDF's equally valid bare CR line ending. Keep
|
|
16
|
+
# every byte-oriented operation on the original IO -- especially reads
|
|
17
|
+
# of arbitrary binary stream bodies -- and adapt only that line API.
|
|
18
|
+
class UniversalLineReader
|
|
19
|
+
def initialize(io)
|
|
20
|
+
@io = io
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def gets
|
|
24
|
+
line = +"".b
|
|
25
|
+
while (byte = @io.getbyte)
|
|
26
|
+
line << byte
|
|
27
|
+
next unless [10, 13].include?(byte)
|
|
28
|
+
|
|
29
|
+
consume_lf_after_cr(line) if byte == 13
|
|
30
|
+
break
|
|
31
|
+
end
|
|
32
|
+
line unless line.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def method_missing(name, *, &)
|
|
36
|
+
return super unless @io.respond_to?(name)
|
|
37
|
+
|
|
38
|
+
@io.public_send(name, *, &)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def respond_to_missing?(name, include_private = false)
|
|
42
|
+
@io.respond_to?(name, include_private) || super
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def consume_lf_after_cr(line)
|
|
48
|
+
byte = @io.getbyte
|
|
49
|
+
return unless byte
|
|
50
|
+
return line << byte if byte == 10
|
|
51
|
+
|
|
52
|
+
@io.seek(-1, IO::SEEK_CUR)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
private_constant :UniversalLineReader
|
|
56
|
+
|
|
57
|
+
# Reports a PDF's version and, when it can be read honestly, its
|
|
58
|
+
# DECLARED page count. Dimensions are deliberately absent: reaching
|
|
59
|
+
# the first page's box safely needs a cycle-guarded page-tree walk in
|
|
60
|
+
# both directions, per-element dereferencing and corner
|
|
61
|
+
# normalisation, none of which pdfrb offers -- measured on 0.7.23 and
|
|
62
|
+
# re-verified on 0.7.49, which is what resolves here,
|
|
63
|
+
# `page.media_box` hangs forever on a self-referential `/Parent`, and
|
|
64
|
+
# `[0 0]` reads back as a 0x0 page invented whole.
|
|
65
|
+
#
|
|
66
|
+
# PDF conformance is `pdfrb`'s pre-write structural check
|
|
67
|
+
# (`Validator.validate`) -- catalog, pages, MediaBox, and reference
|
|
68
|
+
# resolution -- plus the Arlington walk in `PdfArlington` (the issue's
|
|
69
|
+
# "Arlington predicates"), not full ISO 32000 conformance
|
|
70
|
+
# (03-conform.md, D16). There
|
|
71
|
+
# is no separate structural pre-pass for PDF the way PNG, SVG and EMF
|
|
72
|
+
# have one (D23): the plan's own Design table names
|
|
73
|
+
# `Validator.validate` as the whole structural check, so this handler
|
|
74
|
+
# adds nothing on top of it.
|
|
75
|
+
#
|
|
76
|
+
# This handler implements `conform` only. `inspect` for PDF is a
|
|
77
|
+
# different unit of work.
|
|
78
|
+
class Pdf < Base
|
|
79
|
+
formats :pdf
|
|
80
|
+
|
|
81
|
+
profiles(*PdfProfiles.profiles)
|
|
82
|
+
|
|
83
|
+
def self.levels_for(profile) = PdfProfiles.levels_for(profile)
|
|
84
|
+
|
|
85
|
+
# Builds the `Report` a conform operation returns. A sibling class
|
|
86
|
+
# rather than instance methods, matching `Handlers::Png`'s own
|
|
87
|
+
# `ConformanceMapper` -- the mapping owes nothing to any metadata
|
|
88
|
+
# interpretation this handler might grow later.
|
|
89
|
+
class ConformanceMapper
|
|
90
|
+
# Measured against the installed pdfrb gem (0.7.49, resolved from
|
|
91
|
+
# claricle.gemspec's `~> 0.7.23`) rather than assumed --
|
|
92
|
+
# 03-conform.md's own summary is a starting point, not a source,
|
|
93
|
+
# and it is what this class's own tests pin.
|
|
94
|
+
#
|
|
95
|
+
# A catalog-less document (no `/Root` in the trailer, or a
|
|
96
|
+
# `/Root` pointing at an oid absent from the xref) raises
|
|
97
|
+
# `Pdfrb::Error` ("Document has no Catalog"). For THAT shape it
|
|
98
|
+
# is not `check_catalog` that raises -- it only appends a string
|
|
99
|
+
# to the error array and returns; the raise comes from
|
|
100
|
+
# `check_pages` walking into `Document::Pages#pages_root`, which
|
|
101
|
+
# re-derives the catalog and raises when it is nil.
|
|
102
|
+
#
|
|
103
|
+
# `check_catalog` DOES raise directly for a related but distinct
|
|
104
|
+
# shape: a `/Root` reference the xref marks "in use" but whose
|
|
105
|
+
# bytes cannot be found anywhere in the file (a corrupt offset
|
|
106
|
+
# with no matching "N G obj" elsewhere either). There,
|
|
107
|
+
# `document.catalog` itself raises `Pdfrb::MalformedPdfError` --
|
|
108
|
+
# a `Pdfrb::Error` subclass, not the base class, and not the
|
|
109
|
+
# "Document has no Catalog" message -- from
|
|
110
|
+
# `ObjectReader#recover_parse`. Rescuing the base `Pdfrb::Error`
|
|
111
|
+
# class (not just literal instances of it) is what catches this,
|
|
112
|
+
# and every other subclass the gem may raise, on the same
|
|
113
|
+
# reasoning: pdfrb's own error hierarchy reporting a structural
|
|
114
|
+
# fault is the same nonconformance an ordinary returned issue
|
|
115
|
+
# would carry.
|
|
116
|
+
#
|
|
117
|
+
# A `/Pages` reference to a missing object raises `NoMethodError:
|
|
118
|
+
# undefined method '[]' for nil` from `Document::Pages#walk`,
|
|
119
|
+
# which subscripts the unresolved node directly.
|
|
120
|
+
#
|
|
121
|
+
# `Document.open` itself can ALSO raise: a `startxref` pointer
|
|
122
|
+
# that resolves to neither an `xref` keyword nor a parseable
|
|
123
|
+
# object -- an ordinary corrupt-file shape, not one any fixture
|
|
124
|
+
# here had tried -- reaches `Document#find_xref_keyword`'s
|
|
125
|
+
# `chunk.rindex(...)` with `chunk` nil (a short/absent read past
|
|
126
|
+
# EOF), raising `NoMethodError: undefined method 'rindex' for
|
|
127
|
+
# nil`. Measured live; an earlier version of this handler left
|
|
128
|
+
# `Document.open` outside the rescue on the strength of "every
|
|
129
|
+
# malformed shape measured ... opens without raising", which
|
|
130
|
+
# this shape falsifies -- a contract measured on the shapes
|
|
131
|
+
# tried and wrongly generalised to "every shape". `issues_for`
|
|
132
|
+
# below wraps both pdfrb calls for exactly this reason.
|
|
133
|
+
#
|
|
134
|
+
# Scoped to just those two calls, not to Claricle's own code
|
|
135
|
+
# (03-conform.md: "wrapping the call rather than allowlisting
|
|
136
|
+
# NoMethodError globally") -- `report_for`, `Image#with_path`,
|
|
137
|
+
# and Report/Issue construction are outside `issues_for`'s
|
|
138
|
+
# rescue, so a `NoMethodError` from a real defect in THIS
|
|
139
|
+
# handler's own code, or in `Image`, still propagates to exit 4
|
|
140
|
+
# rather than reading as an ordinary nonconformant file.
|
|
141
|
+
#
|
|
142
|
+
# `SystemStackError` is not in the plan's own table -- found by
|
|
143
|
+
# this handler's own adversarial pass: a `/Pages` node whose own
|
|
144
|
+
# `/Kids` cites itself sends `Document::Pages#walk` into infinite
|
|
145
|
+
# recursion. `SystemStackError` is not a `StandardError`
|
|
146
|
+
# subclass, so the rescue below needs it named explicitly to
|
|
147
|
+
# catch it. Rescuing it here is safe -- Ruby's stack is usable
|
|
148
|
+
# again the moment the exception has unwound to this frame
|
|
149
|
+
# (measured) -- and the user-visible meaning is the same
|
|
150
|
+
# nonconformance a returned issue would carry, so it goes on the
|
|
151
|
+
# allowlist for the same reason the plan puts EMF's truncation
|
|
152
|
+
# `FormatError` on its own: the exit code follows the meaning,
|
|
153
|
+
# not the delegate's reporting style. This does not conflict
|
|
154
|
+
# with the Runner's own `SystemStackError` -> 4 mapping
|
|
155
|
+
# (01-core.md, `Cli::Runner.run`): that is the default for a
|
|
156
|
+
# `SystemStackError` no handler has positively identified, the
|
|
157
|
+
# same role `StandardError` -> 4 plays for `NoMethodError`
|
|
158
|
+
# everywhere this handler does NOT rescue it. A handler
|
|
159
|
+
# reclassifying a MEASURED, deterministic raise as nonconformance
|
|
160
|
+
# is the pattern the plan already uses for EMF's `FormatError`,
|
|
161
|
+
# not an exception to it.
|
|
162
|
+
#
|
|
163
|
+
# Named directly in the `rescue` clause below rather than in a
|
|
164
|
+
# frozen constant: `Pdfrb::Error` does not exist until `require
|
|
165
|
+
# "pdfrb"` has run (D5 -- the gem is loaded lazily, inside
|
|
166
|
+
# `conformance_report`), and a class-level constant is evaluated
|
|
167
|
+
# when this file is required, well before that -- measured, that
|
|
168
|
+
# ordering raised `NameError: uninitialized constant Pdfrb` at
|
|
169
|
+
# load time. A `rescue` clause's exception list is evaluated only
|
|
170
|
+
# when `issues_for` actually runs, by which point the require has
|
|
171
|
+
# already happened.
|
|
172
|
+
|
|
173
|
+
# The code and message 03-conform.md assigns the STRING mapping
|
|
174
|
+
# case -- a returned error, not a raised one.
|
|
175
|
+
STRUCTURE_CODE = "PDF_STRUCTURE"
|
|
176
|
+
|
|
177
|
+
# A sibling, stable code for the cases pdfrb reports by raising
|
|
178
|
+
# instead of returning. Distinct from STRUCTURE_CODE so a caller
|
|
179
|
+
# can still tell "the delegate told us this directly" from "the
|
|
180
|
+
# delegate's own check could not run at all" -- and a fixed
|
|
181
|
+
# message rather than the raised exception's own text, which for
|
|
182
|
+
# `NoMethodError` ("undefined method '[]' for nil") would leak a
|
|
183
|
+
# Ruby internal rather than say anything about the PDF.
|
|
184
|
+
UNREADABLE_CODE = "PDF_STRUCTURE_UNREADABLE"
|
|
185
|
+
UNREADABLE_MESSAGE = "PDF structure could not be validated"
|
|
186
|
+
|
|
187
|
+
def self.report(image, profile: nil, level: nil)
|
|
188
|
+
# `image.with_path`, not `image.with_source`: `Document.open`
|
|
189
|
+
# takes a path (or an IO through its block form).
|
|
190
|
+
issues = image.with_path { |path| issues_for(path, profile: profile, level: level) }
|
|
191
|
+
report_for(image, issues, profile: profile)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def self.report_for(image, issues, profile:)
|
|
195
|
+
Models::Report.new(
|
|
196
|
+
source_path: image.path,
|
|
197
|
+
format: image.format.to_s,
|
|
198
|
+
issues: issues,
|
|
199
|
+
profile: profile&.to_s,
|
|
200
|
+
validator_version: profile ? ::Pdfrb::VERSION : nil
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The two pdfrb calls the structural check makes, each with its own
|
|
205
|
+
# rescue -- see the class comment above for why both need one.
|
|
206
|
+
# `issue_from` (Claricle's own `Models::Issue` construction) runs in
|
|
207
|
+
# `issues_for` below, OUTSIDE these rescues: a `NoMethodError` from
|
|
208
|
+
# this handler's own code propagates, rather than reading as an
|
|
209
|
+
# ordinary nonconformant file.
|
|
210
|
+
def self.open_document(path)
|
|
211
|
+
::Pdfrb::Document.open(path)
|
|
212
|
+
rescue ::Pdfrb::Error, NoMethodError, SystemStackError
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def self.structure_errors(document)
|
|
217
|
+
::Pdfrb::Validator.validate(document)
|
|
218
|
+
rescue ::Pdfrb::Error, NoMethodError, SystemStackError
|
|
219
|
+
:malformed
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Both checks, structural issues first. The Arlington walk runs
|
|
223
|
+
# FIRST and on the untouched document: `Validator.validate` repairs
|
|
224
|
+
# what it inspects (measured: a Catalog with no /Pages gains one),
|
|
225
|
+
# which would hide the very key the walk reports. It also runs when
|
|
226
|
+
# the structural check raises -- a Catalog with no /Pages makes it
|
|
227
|
+
# raise -- because the Arlington issue is what names the key.
|
|
228
|
+
def self.issues_for(path, profile:, level:)
|
|
229
|
+
document = open_document(path)
|
|
230
|
+
return [unreadable_issue] unless document
|
|
231
|
+
|
|
232
|
+
arlington = PdfArlington.issues(document)
|
|
233
|
+
errors = structure_errors(document)
|
|
234
|
+
structural = errors == :malformed ? [unreadable_issue] : errors.map { |message| issue_from(message) }
|
|
235
|
+
structural.concat(arlington)
|
|
236
|
+
return structural if profile.nil? || structural.any?
|
|
237
|
+
|
|
238
|
+
PdfProfiles.issues(document, profile, level)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def self.issue_from(message)
|
|
242
|
+
Models::Issue.new(severity: "error", code: STRUCTURE_CODE, message: message, location: nil)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def self.unreadable_issue
|
|
246
|
+
Models::Issue.new(severity: "error", code: UNREADABLE_CODE,
|
|
247
|
+
message: UNREADABLE_MESSAGE, location: nil)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
private_class_method :report_for, :open_document, :structure_errors, :issues_for,
|
|
251
|
+
:issue_from, :unreadable_issue
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
private_constant :ConformanceMapper
|
|
255
|
+
|
|
256
|
+
def conformance_report(image, profile: nil, level: nil)
|
|
257
|
+
require "pdfrb"
|
|
258
|
+
|
|
259
|
+
ConformanceMapper.report(image, profile: profile, level: level)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# A PDF header line longer than a kilobyte is not a header. The
|
|
263
|
+
# read is HEADER_SCAN_BYTES + 1 so "the first line is exactly 1024
|
|
264
|
+
# bytes and ends" is distinguishable from "the first line is at
|
|
265
|
+
# least 1025 bytes" -- measured: `read(1025)` returns 1024 bytes at
|
|
266
|
+
# a 1024-byte EOF and 1025 otherwise.
|
|
267
|
+
HEADER_SCAN_BYTES = 1024
|
|
268
|
+
|
|
269
|
+
# Body-derived values get the same order-of-magnitude publication
|
|
270
|
+
# bound as the header read. The page-count comparison is numeric so
|
|
271
|
+
# refusing an attacker-sized Integer never converts it back to an
|
|
272
|
+
# attacker-sized String.
|
|
273
|
+
#
|
|
274
|
+
# This bounds publication, not parsing. pdfrb has already
|
|
275
|
+
# materialised the Integer or Symbol before this handler sees it;
|
|
276
|
+
# bounding those source tokens would require replacing its tokenizer
|
|
277
|
+
# and compressed-object-stream path rather than enforcing our output
|
|
278
|
+
# contract here.
|
|
279
|
+
MAX_PAGE_COUNT = (10**HEADER_SCAN_BYTES) - 1
|
|
280
|
+
|
|
281
|
+
# An interactive budget, NOT a safe upper bound, and the difference
|
|
282
|
+
# is the whole of the tradeoff.
|
|
283
|
+
#
|
|
284
|
+
# It bounds TIME, not memory. Measured: a 190 KB file whose object
|
|
285
|
+
# stream decodes to 200 MB allocated +190.8 MB *inside* a
|
|
286
|
+
# five-second window and then timed out, so the deadline fired and
|
|
287
|
+
# the memory was spent anyway.
|
|
288
|
+
#
|
|
289
|
+
# It is justified by the inputs that are actually slow rather than
|
|
290
|
+
# by any claim about all of them. Measured: a `/Prev` pointing at
|
|
291
|
+
# its own xref offset never returns at all; resolving one Catalog
|
|
292
|
+
# from that 190 KB file took 29.61 s. A legitimately slow document
|
|
293
|
+
# -- a large file on slow storage -- is reported "failed" when it
|
|
294
|
+
# was merely slow, and that is the accepted cost. The alternative
|
|
295
|
+
# is a CLI that never returns.
|
|
296
|
+
DEADLINE_SECONDS = 5
|
|
297
|
+
|
|
298
|
+
HEADER_CODE = "pdf.header_unreadable"
|
|
299
|
+
OPEN_CODE = "pdf.unreadable"
|
|
300
|
+
STRUCTURE_CODE = "pdf.structure_unreadable"
|
|
301
|
+
TIMEOUT_CODE = "pdf.timeout"
|
|
302
|
+
|
|
303
|
+
# One code per cause. The four sibling handlers each ship exactly
|
|
304
|
+
# one, because each has exactly one failure event. This handler has
|
|
305
|
+
# four a consumer can tell apart: the header never parsed, the file
|
|
306
|
+
# would not open, the structure would not resolve, and the clock
|
|
307
|
+
# ran out. Collapsing them reported "PDF structure could not be
|
|
308
|
+
# read" for files whose structure was never reached.
|
|
309
|
+
#
|
|
310
|
+
# All FOUR are listed here, so a reader scanning this sees every
|
|
311
|
+
# failure mode. The
|
|
312
|
+
# timeout message interpolates `DEADLINE_SECONDS`, which is defined
|
|
313
|
+
# above, so the number can never drift from the value that produced
|
|
314
|
+
# it -- and there is only ever one deadline, so building the string
|
|
315
|
+
# per call bought nothing but a method and a branch.
|
|
316
|
+
MESSAGES = {
|
|
317
|
+
HEADER_CODE => "PDF header is not a valid version declaration",
|
|
318
|
+
OPEN_CODE => "PDF could not be opened",
|
|
319
|
+
STRUCTURE_CODE => "PDF structure could not be read",
|
|
320
|
+
TIMEOUT_CODE => "PDF could not be read within #{DEADLINE_SECONDS} seconds"
|
|
321
|
+
}.freeze
|
|
322
|
+
|
|
323
|
+
# What a `/Version` Name must look like to be believed. Measured,
|
|
324
|
+
# `catalog.value[:Version]` comes back as a Symbol for a Name, but
|
|
325
|
+
# also as a String, a Float, an Array, a Reference or nil depending
|
|
326
|
+
# on what the file wrote -- and as `:"1.7.2"`, `:X` or `:""` for a
|
|
327
|
+
# Name that is not a version. Only a Symbol within the publication
|
|
328
|
+
# bound and matching this is taken, so both readings of the version
|
|
329
|
+
# refuse `%PDF-1.7.2` for the same reason.
|
|
330
|
+
VERSION_TOKEN = /\A\d+\.\d+\z/
|
|
331
|
+
|
|
332
|
+
private_constant :HEADER_SCAN_BYTES, :MAX_PAGE_COUNT, :DEADLINE_SECONDS,
|
|
333
|
+
:HEADER_CODE, :OPEN_CODE, :STRUCTURE_CODE, :TIMEOUT_CODE,
|
|
334
|
+
:MESSAGES, :VERSION_TOKEN
|
|
335
|
+
|
|
336
|
+
# The file's own bytes decide the version, never the delegate.
|
|
337
|
+
# `Document.open` succeeds on an empty file and the document it
|
|
338
|
+
# returns still REPORTS a version -- measured, `"1.4"` for empty,
|
|
339
|
+
# for garbage and for a `%PDF-x.y` header alike. A fabricated
|
|
340
|
+
# default is worse than no answer, so the gate reads the
|
|
341
|
+
# declaration itself and reports the token it captured.
|
|
342
|
+
#
|
|
343
|
+
# The header is only HALF the answer. A PDF may raise its version
|
|
344
|
+
# in the Catalog's `/Version`, and the reported version is the
|
|
345
|
+
# NUMERIC MAXIMUM of the two -- see `highest_version`.
|
|
346
|
+
module VersionGate
|
|
347
|
+
# A COMPLETE declaration: the first line carries the version and
|
|
348
|
+
# nothing after it but optional spaces or tabs.
|
|
349
|
+
#
|
|
350
|
+
# WHAT IT REFUSES. `\A%PDF-(\d+\.\d+)` alone validates a numeric
|
|
351
|
+
# PREFIX -- measured, it captures "1.7" for both `%PDF-1.7junk`
|
|
352
|
+
# and `%PDF-1.7.2`, neither of which is version 1.7.
|
|
353
|
+
#
|
|
354
|
+
# WHAT IT TOLERATES, and this is a MEASURED CONCESSION rather
|
|
355
|
+
# than a loosened guard. A real file on this machine --
|
|
356
|
+
# `duck-small.pdf`, written by ImageMagick 4.2.8 in 2001 --
|
|
357
|
+
# begins `%PDF-1.1` then a SPACE then LF. pdfrb reads it, poppler
|
|
358
|
+
# reads it and calls it version 1.1, and this handler used to
|
|
359
|
+
# report `pdf.header_unreadable` for it: a false refusal of a
|
|
360
|
+
# perfectly readable document. Trailing horizontal whitespace is
|
|
361
|
+
# therefore accepted.
|
|
362
|
+
#
|
|
363
|
+
# `[ \t]*(?:[\r\n]|\z)` and NOT the one-character class
|
|
364
|
+
# `[ \t\r\n]`, because the shorter form only has to match the
|
|
365
|
+
# single byte after the digits. Measured, it accepts BOTH
|
|
366
|
+
# `%PDF-1.4 junk` and `%PDF-1.4\tjunk` and reports "1.4" -- the
|
|
367
|
+
# general junk relaxation this gate exists to prevent. Requiring
|
|
368
|
+
# the LINE to end is what keeps the two apart.
|
|
369
|
+
#
|
|
370
|
+
# Space and tab only. PDF's whitespace table also lists NUL and
|
|
371
|
+
# FF; no measured real file needs them here, and generalising
|
|
372
|
+
# past the evidence is the defect this handler keeps paying for.
|
|
373
|
+
GATE = /\A%PDF-(\d+\.\d+)(?=[ \t]*(?:[\r\n]|\z))/
|
|
374
|
+
TERMINATOR = /[\r\n]/
|
|
375
|
+
private_constant :GATE, :TERMINATOR
|
|
376
|
+
|
|
377
|
+
module_function
|
|
378
|
+
|
|
379
|
+
# The capture holds digits and a dot only, so re-tagging it UTF-8
|
|
380
|
+
# is always valid -- confirmed against content carrying 0xFF 0xFE
|
|
381
|
+
# 0x80 right behind the header.
|
|
382
|
+
def version(path)
|
|
383
|
+
prefix = read(path)
|
|
384
|
+
return unless terminated?(prefix)
|
|
385
|
+
|
|
386
|
+
match = GATE.match(prefix)
|
|
387
|
+
match && match[1].force_encoding(Encoding::UTF_8)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# The YIELDED path, not `image.content` and not `with_source`.
|
|
391
|
+
# For a content-born image `with_source` hands over the bytes in
|
|
392
|
+
# memory while pdfrb reads the tempfile `with_path` wrote, so the
|
|
393
|
+
# gate and the delegate would be reading two different objects.
|
|
394
|
+
# Opening the yielded path keeps every read on the same bytes.
|
|
395
|
+
def read(path)
|
|
396
|
+
File.open(path, "rb") { |file| file.read(HEADER_SCAN_BYTES + 1) } || "".b
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# `\z` anchors to the end of the STRING SUPPLIED, not to physical
|
|
400
|
+
# EOF, so without this a bounded read would let the anchor pass
|
|
401
|
+
# on a line it only saw part of. Measured, and the third row is
|
|
402
|
+
# the dangerous one: nine bytes of `%PDF-12.345` capture "12.3"
|
|
403
|
+
# -- not a refusal, a plausible WRONG version reported silently.
|
|
404
|
+
#
|
|
405
|
+
# A short read is a real end of file, so `\z` means what it says
|
|
406
|
+
# there. A full read with no terminator in it is a first line of
|
|
407
|
+
# at least HEADER_SCAN_BYTES + 1 bytes, and that is refused.
|
|
408
|
+
def terminated?(prefix)
|
|
409
|
+
prefix.bytesize <= HEADER_SCAN_BYTES || prefix.match?(TERMINATOR)
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
private_constant :VersionGate
|
|
413
|
+
|
|
414
|
+
# THE DELEGATE BOUNDARY: every call that crosses into pdfrb, and the
|
|
415
|
+
# single rescue that covers them.
|
|
416
|
+
#
|
|
417
|
+
# Grouped here so the rescue can sit around ONE delegate expression
|
|
418
|
+
# at a time. While `guarded` wrapped whole handler methods, the
|
|
419
|
+
# allowlist also covered Claricle's own code, and a bug in this file
|
|
420
|
+
# was reported as a corrupt PDF instead of crashing.
|
|
421
|
+
#
|
|
422
|
+
# Included rather than `module_function`, so `guarded` is one
|
|
423
|
+
# instance method every read here and in `MetadataGate` shares.
|
|
424
|
+
#
|
|
425
|
+
# pdfrb resolves by object NUMBER alone -- measured, `/MediaBox 4 9 R`
|
|
426
|
+
# returns object `4 0`, and a compressed object addressed `1 1 R`
|
|
427
|
+
# resolves happily because `add_compressed` never records a
|
|
428
|
+
# generation for the reader to compare. So every reference this
|
|
429
|
+
# handler follows is checked against the xref before resolution,
|
|
430
|
+
# and that check is the entire generation guarantee.
|
|
431
|
+
module Resolver
|
|
432
|
+
RESOLVABLE = %i[in_use compressed].freeze
|
|
433
|
+
private_constant :RESOLVABLE
|
|
434
|
+
|
|
435
|
+
private
|
|
436
|
+
|
|
437
|
+
# One list, named once, because it is one policy. NOT a hoisted
|
|
438
|
+
# constant: `::Pdfrb::Error` cannot be resolved at class-definition
|
|
439
|
+
# time while the require is lazy -- measured, a
|
|
440
|
+
# `PARSE_FAILURES = [::Pdfrb::Error, ...]` constant makes
|
|
441
|
+
# `require "claricle"` itself die with
|
|
442
|
+
# `uninitialized constant Pdfrb (NameError)`. The sibling
|
|
443
|
+
# `metafile.rb:239` can hold its equivalent as a constant only
|
|
444
|
+
# because `metafile.rb:3` requires `emf` at the top of the file.
|
|
445
|
+
# A method defers the lookup to call time, when pdfrb is loaded.
|
|
446
|
+
#
|
|
447
|
+
# `Errno::EINVAL` is deliberately NOT in the list. It is reachable
|
|
448
|
+
# only from a negative `/Prev` consumed during `open`, so it
|
|
449
|
+
# belongs to that rescue alone; adding it to `guarded` would widen
|
|
450
|
+
# the gates for a class they cannot see.
|
|
451
|
+
def parse_failures
|
|
452
|
+
[::Pdfrb::Error, NoMethodError, TypeError, RangeError,
|
|
453
|
+
ArgumentError, SystemStackError]
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Returns nil when the DELEGATE could not read what was asked for.
|
|
457
|
+
#
|
|
458
|
+
# Every call wraps ONE pdfrb expression. The shape checks are what
|
|
459
|
+
# make that possible: `typed` refuses anything whose `.value` is
|
|
460
|
+
# not a Hash rather than letting `7[](:Type)` raise, and the `&.`
|
|
461
|
+
# on every value `resolve` may refuse stops those raising at all.
|
|
462
|
+
#
|
|
463
|
+
# `NoMethodError` stays on the list for pdfrb itself. Everywhere
|
|
464
|
+
# else it signals a broken delegate; here pdfrb raises it for
|
|
465
|
+
# ordinary corrupt files -- a free object-stream xref entry, a
|
|
466
|
+
# trailing backslash in a compressed body -- and nothing at the
|
|
467
|
+
# call site distinguishes the two. Crashing on a corrupt PDF is
|
|
468
|
+
# worse.
|
|
469
|
+
def guarded
|
|
470
|
+
yield
|
|
471
|
+
rescue *parse_failures
|
|
472
|
+
nil
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# What proves a document exists at all. A header-only `%PDF-1.4\n`
|
|
476
|
+
# passes the version gate -- the version is genuinely readable --
|
|
477
|
+
# and opens without complaint; only this refuses it.
|
|
478
|
+
#
|
|
479
|
+
# The trailer nil check is part of the gate, not a rescue.
|
|
480
|
+
# Measured: `document.trailer` is nil on a header-only file, on an
|
|
481
|
+
# empty one, on garbage and on one truncated mid-body, so
|
|
482
|
+
# `trailer[:Root]` raises `NoMethodError` before any check runs.
|
|
483
|
+
# Resting the refusal on that would rest it on this handler's own
|
|
484
|
+
# missing nil check rather than on pdfrb's error handling.
|
|
485
|
+
#
|
|
486
|
+
# Returns BOTH checked objects, so the Catalog survives to be
|
|
487
|
+
# asked for its `/Version` instead of being resolved a second
|
|
488
|
+
# time.
|
|
489
|
+
#
|
|
490
|
+
# The node stays the flag, and the DESTRUCTURE at the call site is
|
|
491
|
+
# what keeps it so: `catalog, progress.node =` reads nil out of a
|
|
492
|
+
# nil return and out of `[catalog, nil]` alike, so a Catalog that
|
|
493
|
+
# resolves while its `/Pages` does not still leaves `progress.node`
|
|
494
|
+
# nil and reports "failed".
|
|
495
|
+
def structure_gate(document)
|
|
496
|
+
trailer = guarded { document.trailer }
|
|
497
|
+
return unless trailer
|
|
498
|
+
|
|
499
|
+
catalog = typed(resolve(document, guarded { trailer[:Root] }), :Catalog)
|
|
500
|
+
return unless catalog
|
|
501
|
+
|
|
502
|
+
[catalog, typed(resolve(document, catalog.value[:Pages]), :Pages)]
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# The `&.` is load-bearing, not defensive noise: `resolve` returns
|
|
506
|
+
# nil on every refusal, and a bare `.value` would raise
|
|
507
|
+
# `NoMethodError`.
|
|
508
|
+
#
|
|
509
|
+
# The Hash check is what keeps this handler's own logic out of
|
|
510
|
+
# `guarded`. A dictionary's `.value` is a plain Hash, but an
|
|
511
|
+
# indirect `/Root` resolving to a scalar used to make this
|
|
512
|
+
# `7[](:Type)` -- measured, a `TypeError` raised by THIS handler
|
|
513
|
+
# and swallowed as if pdfrb had refused the file. The outcome is
|
|
514
|
+
# the same `structure_unreadable` either way; the difference is
|
|
515
|
+
# that a real bug here now escapes instead of hiding behind it.
|
|
516
|
+
def typed(object, name)
|
|
517
|
+
value = object&.value
|
|
518
|
+
return unless value.is_a?(::Hash)
|
|
519
|
+
|
|
520
|
+
object if value[:Type] == name
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# Anything that is not a Reference is refused DETERMINISTICALLY
|
|
524
|
+
# rather than left to raise downstream. `Document#object` returns
|
|
525
|
+
# a non-Reference unchanged -- measured, `object(nil)` is nil,
|
|
526
|
+
# `object(7)` is 7 -- so an absent, dangling or scalar `/Pages`
|
|
527
|
+
# used to reach a `NoMethodError` on the `.value` after it and be
|
|
528
|
+
# right by accident.
|
|
529
|
+
#
|
|
530
|
+
# A `/Pages` written as a DIRECT dictionary is refused too. PDF
|
|
531
|
+
# requires it to be indirect and every file measured here writes
|
|
532
|
+
# it that way, but a producer that inlined it would be reported
|
|
533
|
+
# "failed". Accepted, and recorded rather than hidden.
|
|
534
|
+
#
|
|
535
|
+
# For a COMPRESSED reference `document.object` alone cannot prove
|
|
536
|
+
# the object it received is the object the file recorded at that
|
|
537
|
+
# index: pdfrb's object-stream reader discards the number stored
|
|
538
|
+
# there and wraps the value with the number that was asked for.
|
|
539
|
+
# `compressed_oid_matches?` below is what closes that gap by
|
|
540
|
+
# reading the ObjStm's own header directly.
|
|
541
|
+
def resolve(document, value)
|
|
542
|
+
guarded { checked(document, value) }
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def checked(document, value)
|
|
546
|
+
return unless value.is_a?(::Pdfrb::Model::Reference)
|
|
547
|
+
|
|
548
|
+
entry = document.xref&.[](value.oid)
|
|
549
|
+
return unless entry && RESOLVABLE.include?(entry.type)
|
|
550
|
+
return unless entry.gen.to_i == value.gen
|
|
551
|
+
return unless resolvable_compressed?(document, entry, value.oid)
|
|
552
|
+
|
|
553
|
+
document.object(value)
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# A no-op for every non-compressed entry, so `checked` pays this
|
|
557
|
+
# branch's cost without paying `compressed_oid_matches?`'s.
|
|
558
|
+
def resolvable_compressed?(document, entry, oid)
|
|
559
|
+
entry.type != :compressed || compressed_oid_matches?(document, entry, oid)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# ISO 32000-1 7.5.7: the xref's declared object number for a
|
|
563
|
+
# compressed object and the number the object stream's OWN header
|
|
564
|
+
# records at that index shall agree. pdfrb's
|
|
565
|
+
# `ObjectReader#load_from_objstm` (measured against pdfrb-0.7.49
|
|
566
|
+
# lib/pdfrb/source/object_reader.rb:119-129) discards the stored
|
|
567
|
+
# number and relabels whatever value it finds at the index with
|
|
568
|
+
# the oid the caller asked for -- so an xref entry whose `index`
|
|
569
|
+
# disagrees with what the stream itself declares silently returns
|
|
570
|
+
# a different object's value under the right object's number.
|
|
571
|
+
# Measured: an otherwise well-formed compressed Pages node with
|
|
572
|
+
# `/Count 1` swapped for one at another index declaring `/Count
|
|
573
|
+
# 999` resolves and publishes 999 with no error anywhere.
|
|
574
|
+
#
|
|
575
|
+
# `container&.type == :in_use` is checked FIRST and separately
|
|
576
|
+
# from the oid comparison below. An object stream is never itself
|
|
577
|
+
# stored inside another object stream, so if the xref claims the
|
|
578
|
+
# CONTAINING stream is itself `:compressed`, the `document.object`
|
|
579
|
+
# call a few lines down would recurse into pdfrb's own
|
|
580
|
+
# compressed-resolution path a second time with none of this
|
|
581
|
+
# method's guards applied to that inner hop -- measured, without
|
|
582
|
+
# this check that recursion runs until `SystemStackError`, which
|
|
583
|
+
# `guarded` happens to catch but only after paying for the whole
|
|
584
|
+
# stack. Refusing immediately is cheaper and does not depend on a
|
|
585
|
+
# rescue two frames away.
|
|
586
|
+
#
|
|
587
|
+
# Re-parses only the ObjStm's HEADER (the `oid offset` pairs
|
|
588
|
+
# before `/First`), not the values: `ObjectStreamReader.read`
|
|
589
|
+
# (pdfrb-0.7.49 lib/pdfrb/source/object_stream_reader.rb:18-21)
|
|
590
|
+
# parses the header exactly this way, but it also eagerly parses
|
|
591
|
+
# every declared VALUE, which pdfrb already does once per ObjStm
|
|
592
|
+
# and caches privately (`@objstm_cache`, unreachable from here).
|
|
593
|
+
# Calling that method a second time here would double the
|
|
594
|
+
# value-parsing cost of every compressed resolution; this reads
|
|
595
|
+
# only the bytes needed to answer the oid question.
|
|
596
|
+
def compressed_oid_matches?(document, entry, expected_oid)
|
|
597
|
+
pairs = objstm_header_pairs(document, entry.obj_stm_oid)
|
|
598
|
+
return false unless pairs
|
|
599
|
+
|
|
600
|
+
slot = entry.index.to_i * 2
|
|
601
|
+
return false unless slot >= 0 && slot < pairs.length
|
|
602
|
+
|
|
603
|
+
pairs[slot].to_i == expected_oid
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# Memoised per `obj_stm_oid`, on the handler instance. `Image#handler`
|
|
607
|
+
# (image.rb) builds a fresh handler per call and this method's own
|
|
608
|
+
# instance runs exactly one `Pdfrb::Document.open` (`open_document`
|
|
609
|
+
# above), so the cache cannot outlive, or leak across, the one
|
|
610
|
+
# document it was built for.
|
|
611
|
+
#
|
|
612
|
+
# Without this, a shared object stream pays `objstm_header_tokens`'s
|
|
613
|
+
# decode once per COMPRESSED OBJECT the handler resolves out of it,
|
|
614
|
+
# not once per stream. Measured: instrumenting
|
|
615
|
+
# `Pdfrb::Model::Cos::Stream#decoded_stream` (which pdfrb does not
|
|
616
|
+
# memoise -- confirmed against pdfrb-0.7.49
|
|
617
|
+
# lib/pdfrb/model/cos/stream.rb, every call re-runs
|
|
618
|
+
# `Pdfrb::Filter.apply`) on the default 3-object fixture, where the
|
|
619
|
+
# handler resolves 2 distinct compressed oids (Catalog, Pages) from
|
|
620
|
+
# one ObjStm, gave 4 decodes -- 1 for the xref stream, 2 for this
|
|
621
|
+
# method (one per resolve), 1 more inside pdfrb's own
|
|
622
|
+
# `ObjectReader#load_from_objstm`. The comment on
|
|
623
|
+
# `compressed_oid_matches?` above already accounts for paying that
|
|
624
|
+
# last one twice (ours plus pdfrb's, once); it did not account for
|
|
625
|
+
# multiplying by every compressed object touched, and the file's own
|
|
626
|
+
# `DEADLINE_SECONDS` comment treats decode cost as a resource this
|
|
627
|
+
# handler cannot let scale with attacker-controlled shape.
|
|
628
|
+
def objstm_header_pairs(document, obj_stm_oid)
|
|
629
|
+
cache = (@objstm_header_pairs ||= {})
|
|
630
|
+
return cache[obj_stm_oid] if cache.key?(obj_stm_oid)
|
|
631
|
+
|
|
632
|
+
cache[obj_stm_oid] = uncached_objstm_header_pairs(document, obj_stm_oid)
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Returns nil for anything that keeps the header from being read
|
|
636
|
+
# safely: the declared container is not a genuine in-use stream
|
|
637
|
+
# (see `compressed_oid_matches?`'s comment on why that is checked
|
|
638
|
+
# before touching the delegate at all), or `/First` does not
|
|
639
|
+
# describe a real prefix of the decoded bytes.
|
|
640
|
+
def uncached_objstm_header_pairs(document, obj_stm_oid)
|
|
641
|
+
container = document.xref&.[](obj_stm_oid)
|
|
642
|
+
return unless container&.type == :in_use
|
|
643
|
+
|
|
644
|
+
objstm = document.object(::Pdfrb::Model::Reference.new(obj_stm_oid, 0))
|
|
645
|
+
return unless objstm.is_a?(::Pdfrb::Model::Cos::Stream)
|
|
646
|
+
|
|
647
|
+
objstm_header_tokens(objstm)
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# The type/sign check on `first` runs BEFORE `decoded_stream` --
|
|
651
|
+
# an invalid `/First` is refused without paying for a decode at
|
|
652
|
+
# all, rather than decoding first and discarding the result.
|
|
653
|
+
def objstm_header_tokens(objstm)
|
|
654
|
+
first = objstm.value[:First]
|
|
655
|
+
return unless first.is_a?(::Integer) && !first.negative?
|
|
656
|
+
|
|
657
|
+
decoded = objstm.decoded_stream
|
|
658
|
+
return unless decoded && first <= decoded.bytesize
|
|
659
|
+
|
|
660
|
+
decoded.byteslice(0, first).split(/\s+/)
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
include Resolver
|
|
664
|
+
|
|
665
|
+
private_constant :Resolver
|
|
666
|
+
|
|
667
|
+
# The publication boundary for both body-derived metadata values.
|
|
668
|
+
# Kept as private instance methods, so each read reaches the
|
|
669
|
+
# handler's own guarded `resolve` and can still fail independently.
|
|
670
|
+
#
|
|
671
|
+
# A module rather than two more methods in the class body: this
|
|
672
|
+
# file's other two helper groups are modules for the same reason,
|
|
673
|
+
# and folding these back into `Pdf` puts it over the project's
|
|
674
|
+
# RuboCop class-length budget.
|
|
675
|
+
module MetadataGate
|
|
676
|
+
private
|
|
677
|
+
|
|
678
|
+
# The Catalog's own claim, believed only when it is a well-formed
|
|
679
|
+
# Name. Read RAW through `.value`, like every other key here: the
|
|
680
|
+
# typed `Dictionary#[]` coerces and mutates the node in place.
|
|
681
|
+
#
|
|
682
|
+
# A Reference goes through the guarded `resolve`, never
|
|
683
|
+
# `document.object`, or the generation guard is bypassed for this
|
|
684
|
+
# key alone -- pdfrb resolves by object number and would hand back
|
|
685
|
+
# an object the file never authorised.
|
|
686
|
+
#
|
|
687
|
+
# `Document#version` and `Catalog#version_override` are both
|
|
688
|
+
# refused: the first fabricates "1.4" for an unreadable header,
|
|
689
|
+
# which is the whole reason this handler reads the bytes itself.
|
|
690
|
+
def catalog_version(document, catalog)
|
|
691
|
+
raw = catalog.value[:Version]
|
|
692
|
+
raw = resolve(document, raw)&.value if raw.is_a?(::Pdfrb::Model::Reference)
|
|
693
|
+
return unless raw.is_a?(::Symbol)
|
|
694
|
+
|
|
695
|
+
token = raw.name
|
|
696
|
+
token if token.bytesize <= HEADER_SCAN_BYTES && token.match?(VERSION_TOKEN)
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# `pages.count` is never called. Measured, it is not a declaration:
|
|
700
|
+
# sometimes the declared `/Count`, sometimes computed by walking
|
|
701
|
+
# `/Kids`, sometimes fabricated -- and on a self-cycling `/Kids`
|
|
702
|
+
# tree with `/Count` missing it raises `SystemStackError`.
|
|
703
|
+
# `Catalog#page_count` is forbidden too, because calling it MUTATES
|
|
704
|
+
# the Catalog: measured, `catalog.value[:Pages]` is a `Reference`
|
|
705
|
+
# before and a resolved `PageTreeNode` after, which leaves the
|
|
706
|
+
# generation guard with nothing to check.
|
|
707
|
+
#
|
|
708
|
+
# THE DECLARED COUNT, which can disagree with the real one without
|
|
709
|
+
# bound: a document with one leaf page declaring `/Count 999`
|
|
710
|
+
# reports 999. Nothing traverses the page tree to check it.
|
|
711
|
+
#
|
|
712
|
+
# `Integer` is a correctness requirement, not taste. Measured,
|
|
713
|
+
# `Models::Inspection` refuses a non-core leaf in `meta` with
|
|
714
|
+
# `Lutaml::Model::ValidationError`, and a Name arrives as a Symbol
|
|
715
|
+
# -- so putting a raw `:Bad` in `meta["pages"]` would not report a
|
|
716
|
+
# bad count, it would raise out of the model. The magnitude check is
|
|
717
|
+
# the publication bound: at most HEADER_SCAN_BYTES decimal digits.
|
|
718
|
+
def count_read(document, node)
|
|
719
|
+
raw = node.value[:Count]
|
|
720
|
+
raw = resolve(document, raw)&.value if raw.is_a?(::Pdfrb::Model::Reference)
|
|
721
|
+
raw if raw.is_a?(::Integer) && !raw.negative? && raw <= MAX_PAGE_COUNT
|
|
722
|
+
end
|
|
723
|
+
end
|
|
724
|
+
include MetadataGate
|
|
725
|
+
|
|
726
|
+
private_constant :MetadataGate
|
|
727
|
+
|
|
728
|
+
# What the timeout block managed to establish. A local carrier and
|
|
729
|
+
# never an ivar: `image.rb:233` builds a handler per call precisely
|
|
730
|
+
# so it holds no per-call state, and the outer rescue has to tell
|
|
731
|
+
# an expiry BEFORE the structure gate from one after it.
|
|
732
|
+
Progress = Struct.new(:version, :node, :page_count, :code, :version_settled)
|
|
733
|
+
private_constant :Progress
|
|
734
|
+
|
|
735
|
+
# One deadline over every potentially unbounded step in the normal
|
|
736
|
+
# operation, including construction of its successful result, never
|
|
737
|
+
# one per stage: five stages each granted five seconds is a
|
|
738
|
+
# twenty-five second worst case wearing a five-second label.
|
|
739
|
+
#
|
|
740
|
+
# An expiry after the VERSION IS SETTLED still returns `ok`. That
|
|
741
|
+
# recovery result is necessarily built after the clock has fired, but
|
|
742
|
+
# only after its page count is cleared; the remaining published
|
|
743
|
+
# strings are capped at HEADER_SCAN_BYTES, so the recovery has
|
|
744
|
+
# bounded input size.
|
|
745
|
+
#
|
|
746
|
+
# The expiry is caught OUTSIDE the block, and the default form is
|
|
747
|
+
# not a style choice. Measured on Ruby 3.4.8: `Timeout.timeout(n)`
|
|
748
|
+
# raises an internal `Timeout::ExitException` inside the block --
|
|
749
|
+
# not a `StandardError` -- and translates it to `Timeout::Error`
|
|
750
|
+
# only outside, so a stage-local rescue catches nothing. The
|
|
751
|
+
# explicit `Timeout.timeout(n, Timeout::Error)` form is worse: it
|
|
752
|
+
# makes a real `Timeout::Error` fly inside the block, where the
|
|
753
|
+
# inner rescue swallows it and the outer one sees nothing.
|
|
754
|
+
#
|
|
755
|
+
# `require "pdfrb"` sits in this method rather than at the top of
|
|
756
|
+
# the file because `registry.rb` requires every handler eagerly.
|
|
757
|
+
# Measured, best of 9 on a monotonic clock: bare `ruby -e ""` is
|
|
758
|
+
# 33.1 ms and `ruby -e 'require "pdfrb"'` is 76.8 ms, so a
|
|
759
|
+
# top-level require would put ~43 ms on every `claricle version`
|
|
760
|
+
# and every `--help`, for a delegate most invocations never touch.
|
|
761
|
+
#
|
|
762
|
+
# It sits OUTSIDE the Timeout block, and that is not cosmetic.
|
|
763
|
+
# Measured: with the require inside, a deadline expiring during it
|
|
764
|
+
# left `Pdfrb` undefined and the next line raised a bare
|
|
765
|
+
# `NameError` -- which is not on any rescue list here, so it
|
|
766
|
+
# escaped `inspection` instead of reporting `"failed"`. The
|
|
767
|
+
# deadline exists to bound reading an untrusted FILE; loading our
|
|
768
|
+
# own dependency is fixed work that no input controls.
|
|
769
|
+
#
|
|
770
|
+
# `version_settled` is the flag, and it is the VERSION rather than
|
|
771
|
+
# the structure gate's node. The reported version is the numeric
|
|
772
|
+
# maximum of the header and the Catalog's `/Version`, so a clock
|
|
773
|
+
# that fires while the Catalog is being read leaves only half the
|
|
774
|
+
# answer. The node was true by then, so the old flag reported "ok"
|
|
775
|
+
# and published the header version -- measured, "1.4" for a file
|
|
776
|
+
# whose Catalog says 1.7. A wrong version called a good read is
|
|
777
|
+
# worse than a "failed", so anything before the version is settled
|
|
778
|
+
# is now `pdf.timeout`.
|
|
779
|
+
#
|
|
780
|
+
# Only the count read and the result construction happen after the
|
|
781
|
+
# flag is set, and both are recoverable: the count is simply
|
|
782
|
+
# omitted.
|
|
783
|
+
def inspection(image)
|
|
784
|
+
progress = Progress.new
|
|
785
|
+
require "pdfrb"
|
|
786
|
+
result = bounded(progress) { run_stages(image, progress) }
|
|
787
|
+
if progress.code
|
|
788
|
+
return failed_inspection(image, code: progress.code,
|
|
789
|
+
message: MESSAGES.fetch(progress.code))
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
result || readable(image, progress)
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
private
|
|
796
|
+
|
|
797
|
+
# The clock, and what an expiry is allowed to conclude. Returns nil
|
|
798
|
+
# on an expiry, so the caller falls back to the recovery result.
|
|
799
|
+
#
|
|
800
|
+
# `||=`, because a code already set names a cause the run actually
|
|
801
|
+
# reached. A structure gate that refused sets STRUCTURE_CODE with
|
|
802
|
+
# the version still unsettled, and a deadline expiring during the
|
|
803
|
+
# unwind after it would otherwise relabel that refusal
|
|
804
|
+
# `pdf.timeout` -- one code per cause, reporting the wrong one.
|
|
805
|
+
def bounded(progress, &)
|
|
806
|
+
Timeout.timeout(DEADLINE_SECONDS, &)
|
|
807
|
+
rescue Timeout::Error
|
|
808
|
+
progress.code ||= TIMEOUT_CODE unless progress.version_settled
|
|
809
|
+
progress.page_count = nil
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# Every read happens inside `with_path`. The handler never calls
|
|
813
|
+
# `image.content` itself: `with_path` calls it once for a
|
|
814
|
+
# content-born image, to write the tempfile pdfrb needs, and that
|
|
815
|
+
# returns the string the image already retains rather than making a
|
|
816
|
+
# second copy. A path-born inspection materialises nothing.
|
|
817
|
+
def run_stages(image, progress)
|
|
818
|
+
image.with_path do |path|
|
|
819
|
+
progress.version = VersionGate.version(path)
|
|
820
|
+
unless progress.version
|
|
821
|
+
progress.code = HEADER_CODE
|
|
822
|
+
next
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
open_document(path, progress)
|
|
826
|
+
end
|
|
827
|
+
readable(image, progress) unless progress.code
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
module CrDocumentOpening
|
|
831
|
+
private
|
|
832
|
+
|
|
833
|
+
# The BLOCK form, always. Measured on a 1 MB file: the non-block
|
|
834
|
+
# branch is `File.binread(path)` into a `StringIO` held for the
|
|
835
|
+
# document's life, whose `string` is exactly the file; the block
|
|
836
|
+
# form's `doc.io` is a `File` handle that does not respond to
|
|
837
|
+
# `string` at all.
|
|
838
|
+
#
|
|
839
|
+
# `Errno::ENOENT` is deliberately NOT rescued. pdfrb opens the file
|
|
840
|
+
# itself and `with_path` re-yields a path-born image's path without
|
|
841
|
+
# holding the handle, so a file deleted between the header read and
|
|
842
|
+
# this call raises it -- and reporting "failed" would claim the PDF
|
|
843
|
+
# is unreadable when it is simply gone.
|
|
844
|
+
#
|
|
845
|
+
# `opened` narrows this rescue to the OPEN itself. The block form is
|
|
846
|
+
# required (see above), so the rescue would otherwise also cover the
|
|
847
|
+
# stages inside the block, where this handler's own code runs: a bug
|
|
848
|
+
# there was reported as `pdf.unreadable` instead of crashing. Once
|
|
849
|
+
# pdfrb has yielded, every delegate call inside is guarded on its
|
|
850
|
+
# own, so anything still escaping is ours and is re-raised.
|
|
851
|
+
def open_document(path, progress)
|
|
852
|
+
open_native_document(path, progress)
|
|
853
|
+
return unless progress.code == STRUCTURE_CODE && cr_terminated_header?(path)
|
|
854
|
+
|
|
855
|
+
progress.code = nil
|
|
856
|
+
progress.node = nil
|
|
857
|
+
open_cr_document(path, progress)
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
def open_native_document(path, progress)
|
|
861
|
+
opened = false
|
|
862
|
+
::Pdfrb::Document.open(path) do |document|
|
|
863
|
+
opened = true
|
|
864
|
+
read_document(document, progress)
|
|
865
|
+
end
|
|
866
|
+
rescue *parse_failures, Errno::EINVAL
|
|
867
|
+
raise if opened
|
|
868
|
+
|
|
869
|
+
progress.code = OPEN_CODE
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
# A fallback rather than the default path: ordinary documents retain
|
|
873
|
+
# pdfrb's own block-form open, while a document whose header proves it
|
|
874
|
+
# uses CR gets a second parse with only line reads adapted. The File
|
|
875
|
+
# remains the backing store, so neither the PDF nor its streams are
|
|
876
|
+
# materialised or rewritten.
|
|
877
|
+
def open_cr_document(path, progress)
|
|
878
|
+
opened = false
|
|
879
|
+
File.open(path, "rb") do |file|
|
|
880
|
+
document = ::Pdfrb::Document.new(io: UniversalLineReader.new(file))
|
|
881
|
+
opened = true
|
|
882
|
+
read_document(document, progress)
|
|
883
|
+
end
|
|
884
|
+
rescue *parse_failures, Errno::EINVAL
|
|
885
|
+
raise if opened
|
|
886
|
+
|
|
887
|
+
progress.code = OPEN_CODE
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
def cr_terminated_header?(path)
|
|
891
|
+
prefix = VersionGate.read(path)
|
|
892
|
+
cr = prefix.index("\r")
|
|
893
|
+
lf = prefix.index("\n")
|
|
894
|
+
cr && (!lf || cr < lf)
|
|
895
|
+
end
|
|
896
|
+
end
|
|
897
|
+
private_constant :CrDocumentOpening
|
|
898
|
+
include CrDocumentOpening
|
|
899
|
+
|
|
900
|
+
# The stages that run once pdfrb has handed the document over. A
|
|
901
|
+
# method of its own so the rescue above covers the open and nothing
|
|
902
|
+
# else.
|
|
903
|
+
def read_document(document, progress)
|
|
904
|
+
catalog, progress.node = structure_gate(document)
|
|
905
|
+
unless progress.node
|
|
906
|
+
progress.code = STRUCTURE_CODE
|
|
907
|
+
return
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
read_optional_fields(document, catalog, progress)
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
# Everything the gate did NOT have to prove. Each delegate read is
|
|
914
|
+
# guarded on its own, so a Catalog that will not give up its
|
|
915
|
+
# `/Version` still reports the header's, and a `/Count` that raises
|
|
916
|
+
# still leaves the version intact.
|
|
917
|
+
#
|
|
918
|
+
# The flag is set BETWEEN the two reads. Everything above it decides
|
|
919
|
+
# the published version, so a deadline there must report "failed";
|
|
920
|
+
# everything below it only adds the count, which the recovery drops.
|
|
921
|
+
def read_optional_fields(document, catalog, progress)
|
|
922
|
+
progress.version = highest_version(progress.version,
|
|
923
|
+
catalog_version(document, catalog))
|
|
924
|
+
progress.version_settled = true
|
|
925
|
+
progress.page_count = count_read(document, progress.node)
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
# The NUMERIC MAXIMUM, and every word of that is load-bearing.
|
|
929
|
+
#
|
|
930
|
+
# Not "prefer the Catalog": a real file can declare a LOWER
|
|
931
|
+
# `/Version` than its header, and 1.7 is then the true version --
|
|
932
|
+
# poppler agrees. Not a string compare: `"1.10" > "1.9"` is FALSE.
|
|
933
|
+
# Not a float: `1.10 == 1.1`, so 1.10 and 1.1 collide. Comparing
|
|
934
|
+
# integer pairs split on the dot is the only one of the three that
|
|
935
|
+
# gets both directions right.
|
|
936
|
+
def highest_version(header, catalog)
|
|
937
|
+
return header unless catalog
|
|
938
|
+
|
|
939
|
+
[header, catalog].max_by { |version| version.split(".").map(&:to_i) }
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
# `width`, `height`, `dpi` and `color_space` stay nil, and a nil
|
|
943
|
+
# attribute is simply absent from the serialised output.
|
|
944
|
+
def readable(image, progress)
|
|
945
|
+
Models::Inspection.new(
|
|
946
|
+
format: image.format.to_s, parse_status: "ok", meta: metadata(progress)
|
|
947
|
+
)
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
# `count_read` has already validated it down to a non-negative
|
|
951
|
+
# Integer or nil, so this is a page count and not a raw value. Zero
|
|
952
|
+
# is truthy in Ruby, so a document declaring `/Count 0` reports its
|
|
953
|
+
# zero rather than dropping the key.
|
|
954
|
+
def metadata(progress)
|
|
955
|
+
meta = { "version" => progress.version }
|
|
956
|
+
meta["pages"] = progress.page_count if progress.page_count
|
|
957
|
+
meta
|
|
958
|
+
end
|
|
959
|
+
end
|
|
960
|
+
end
|
|
961
|
+
end
|