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,468 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Required here rather than from the entry point: claricle.rb does not
|
|
4
|
+
# require this file yet, so requiring it on its own is the only load path
|
|
5
|
+
# it has. registry.rb also carries its own requires, though for its own
|
|
6
|
+
# stated reason -- it IS required from claricle.rb:10.
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
|
|
9
|
+
module Claricle
|
|
10
|
+
# Everything between "the bytes exist" and "the bytes are on disk or on
|
|
11
|
+
# stdout". Preflight is the constructor, so it cannot be skipped.
|
|
12
|
+
class Writer
|
|
13
|
+
STDOUT_DESTINATION = "-"
|
|
14
|
+
|
|
15
|
+
# The comparisons a name goes through, and the authorities that answer
|
|
16
|
+
# them. `normalize` folds Unicode spellings and is UNCONDITIONAL --
|
|
17
|
+
# comparing canonicalized strings costs nothing and needs no probe.
|
|
18
|
+
# `fold` additionally folds case, which is a filesystem property the
|
|
19
|
+
# probe must be asked about. Collapsing the two into one function and
|
|
20
|
+
# letting a single probe verdict settle both is R-10: on a case-
|
|
21
|
+
# sensitive, normalizing filesystem a pair differing only in Unicode
|
|
22
|
+
# spelling was silently accepted as distinct and one file overwrote the
|
|
23
|
+
# other, exit 0, no warning. `fold` calls `normalize` rather than
|
|
24
|
+
# repeating its guard, so the totality proof has one copy and cannot
|
|
25
|
+
# drift between the two. `raw_canonical` compares locations and
|
|
26
|
+
# `key_for` compares files -- neither is either of these.
|
|
27
|
+
module Naming
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# TOTAL, and the property is what makes it total rather than a list
|
|
31
|
+
# of cases. `unicode_normalize` is defined for exactly those strings
|
|
32
|
+
# whose BYTES are valid UTF-8 read as UTF-8; everything else raises
|
|
33
|
+
# Encoding::CompatibilityError (a non-Unicode encoding) or
|
|
34
|
+
# ArgumentError (an invalid byte sequence). So the guard reinterprets
|
|
35
|
+
# the bytes as UTF-8 and tests validity THERE -- one predicate
|
|
36
|
+
# covering both failure modes. Testing the name AS TAGGED is not
|
|
37
|
+
# enough: "Logo.svg".b is valid ASCII-8BIT and still raises.
|
|
38
|
+
#
|
|
39
|
+
# The fallback returns the raw bytes UNCHANGED -- no case folding
|
|
40
|
+
# here. Case folding belongs to `fold` alone; doing it here too would
|
|
41
|
+
# let the two equivalences drift back into one, which is R-10.
|
|
42
|
+
def normalize(name)
|
|
43
|
+
utf8 = name.dup.force_encoding(Encoding::UTF_8)
|
|
44
|
+
return name.b unless utf8.valid_encoding?
|
|
45
|
+
|
|
46
|
+
utf8.unicode_normalize(:nfc)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# `normalize`'s guard-and-NFC half, plus case folding. The fallback
|
|
50
|
+
# ASCII-case-folds rather than returning the name raw. Returning it
|
|
51
|
+
# raw misses a collision the card forbids -- A\xFF.svg and a\xFF.svg
|
|
52
|
+
# are one file on a case-folding volume. Full Unicode folding is
|
|
53
|
+
# undefined on invalid bytes; ASCII case folding is defined on any
|
|
54
|
+
# byte string, and it cannot over-reject, because folding decides
|
|
55
|
+
# only GROUPING and a grouped pair is then handed to the probe, which
|
|
56
|
+
# answers "distinct" on a case-sensitive volume.
|
|
57
|
+
#
|
|
58
|
+
# `normalized.encoding == Encoding::BINARY` is the fallback
|
|
59
|
+
# discriminator, and it is faithful rather than clever: `normalize`
|
|
60
|
+
# returns UTF-8 on the Unicode path (`unicode_normalize` always does)
|
|
61
|
+
# and ASCII-8BIT on the fallback path (`name.b` always does), so the
|
|
62
|
+
# encoding records which branch ran and cannot disagree with it.
|
|
63
|
+
def fold(name)
|
|
64
|
+
normalized = normalize(name)
|
|
65
|
+
return normalized.tr("A-Z", "a-z") if normalized.encoding == Encoding::BINARY
|
|
66
|
+
|
|
67
|
+
normalized.downcase(:fold)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# File.realdirpath is unusable here: it resolved /var to /private/var
|
|
71
|
+
# but left an existing Foo spelled foo, so it canonicalizes the half
|
|
72
|
+
# that does not need it and not the half that does.
|
|
73
|
+
def raw_canonical(path)
|
|
74
|
+
File.join(resolved_dir(File.dirname(path)), File.basename(path))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# File.stat, NOT lstat, and the split is deliberate: the identity
|
|
78
|
+
# that matters here is the file a write would touch, so a symlink
|
|
79
|
+
# must key as its target. Preflight's existence and type checks use
|
|
80
|
+
# lstat, because there what matters is the directory entry occupying
|
|
81
|
+
# the name -- see `entry_at`.
|
|
82
|
+
#
|
|
83
|
+
# A String key means File.stat did not resolve. That is usually "does
|
|
84
|
+
# not exist", but also covers a dangling symlink, where the name IS
|
|
85
|
+
# occupied; that routing is correct, because a dangling-symlink pair
|
|
86
|
+
# is String-keyed on both sides, lands in one fold group, fires the
|
|
87
|
+
# probe and is refused.
|
|
88
|
+
def key_for(path)
|
|
89
|
+
stat = File.stat(path)
|
|
90
|
+
[stat.dev, stat.ino]
|
|
91
|
+
rescue SystemCallError
|
|
92
|
+
raw_canonical(path)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def resolved_dir(dir)
|
|
96
|
+
File.realpath(dir)
|
|
97
|
+
rescue SystemCallError
|
|
98
|
+
File.expand_path(dir)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Interrogating one directory's case folding. Lazy: zero probes on an
|
|
103
|
+
# ordinary batch, at most one per directory that holds a raw-distinct
|
|
104
|
+
# collision candidate.
|
|
105
|
+
module CaseProbe
|
|
106
|
+
PROBE_PREFIX = ".claricle-case-probe-"
|
|
107
|
+
UNDECIDABLE = :undecidable
|
|
108
|
+
|
|
109
|
+
module_function
|
|
110
|
+
|
|
111
|
+
# Returns true (folds), false (distinguishes) or UNDECIDABLE.
|
|
112
|
+
#
|
|
113
|
+
# The BASENAME is upcased, never the whole path: upcasing the path
|
|
114
|
+
# uppercases every directory component, which on a case-sensitive
|
|
115
|
+
# filesystem does not exist, so the probe would report undecidable
|
|
116
|
+
# everywhere on Linux and refuse every legitimate batch.
|
|
117
|
+
#
|
|
118
|
+
# UNDECIDABLE REFUSES the batch upstream. Treating an unprobeable
|
|
119
|
+
# directory as case-sensitive would be fail-OPEN: it compares more
|
|
120
|
+
# names as distinct and lets a real collision through.
|
|
121
|
+
def measure(dir)
|
|
122
|
+
base = "#{PROBE_PREFIX}#{Process.pid}-#{Random.urandom(8).unpack1("H*")}"
|
|
123
|
+
created = create_probe(File.join(dir, base.upcase))
|
|
124
|
+
File.exist?(File.join(dir, base.downcase))
|
|
125
|
+
rescue SystemCallError
|
|
126
|
+
UNDECIDABLE
|
|
127
|
+
ensure
|
|
128
|
+
File.unlink(created) if created
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Returns the path ONLY once the EXCL open has returned, so a name
|
|
132
|
+
# this probe did not create never becomes something the caller then
|
|
133
|
+
# deletes. Armed before the open, an EEXIST clash would make the
|
|
134
|
+
# cleanup unlink a planted file.
|
|
135
|
+
def create_probe(path)
|
|
136
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL) do
|
|
137
|
+
# Created only to see whether the name folds; nothing is written.
|
|
138
|
+
end
|
|
139
|
+
path
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The collision rules. Everything a user can cause here is an
|
|
144
|
+
# InvocationError, which cli.rb maps to exit 2: the path is unusable
|
|
145
|
+
# and that is knowable before any bytes are generated.
|
|
146
|
+
class Preflight
|
|
147
|
+
def initialize(destinations, sources:, force: false)
|
|
148
|
+
@destinations = destinations
|
|
149
|
+
@sources = sources
|
|
150
|
+
@force = force
|
|
151
|
+
@probed = {}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def check
|
|
155
|
+
@destinations.each { |dest| examine(dest) }
|
|
156
|
+
# A list of pairs, never a Hash keyed by destination: a Hash
|
|
157
|
+
# collapses an exact repeat into one entry, and an exact repeat is
|
|
158
|
+
# precisely what pass 1 catches among destinations that do not yet
|
|
159
|
+
# exist. Measured -- ["a.svg", "a.svg"] was accepted.
|
|
160
|
+
keys = @destinations.map { |dest| [dest, Naming.key_for(dest)] }
|
|
161
|
+
refuse_sources(keys)
|
|
162
|
+
refuse_duplicates(keys)
|
|
163
|
+
refuse_normalization_collisions(keys)
|
|
164
|
+
refuse_fold_collisions(keys)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
# File.dirname and File.basename silently drop a trailing separator,
|
|
170
|
+
# so "build/" was examined as "build", accepted whenever nothing of
|
|
171
|
+
# that name existed, and then failed out of File.link with the STAGE
|
|
172
|
+
# FILENAME in the message -- the leak the directory refusal exists to
|
|
173
|
+
# prevent, reached by a shape that skipped it. That one was already
|
|
174
|
+
# exit 2, since cli.rb:308 maps ENOENT; the neighbouring EISDIR case
|
|
175
|
+
# is the one that reached exit 4. An empty destination leaked the
|
|
176
|
+
# same way. Both are refused here, before any bytes exist.
|
|
177
|
+
#
|
|
178
|
+
# Guard on the NAME, never on the object: a caller may pass a
|
|
179
|
+
# Pathname -- image.rb converts one for the same reason -- and
|
|
180
|
+
# Pathname#empty? tests whether the FILE is empty rather than the
|
|
181
|
+
# string, while Pathname has no #end_with? at all. Guarding on the
|
|
182
|
+
# object refused a zero-byte destination as "empty" and crashed on a
|
|
183
|
+
# missing one with NoMethodError, both of which worked before.
|
|
184
|
+
#
|
|
185
|
+
# File.path, not to_s: it is Ruby's own path coercion, so it accepts
|
|
186
|
+
# exactly what File.dirname already accepted -- a String or anything
|
|
187
|
+
# with #to_path -- and raises the same TypeError for anything else.
|
|
188
|
+
# to_s is lax where this must be strict: it turns nil into "" and an
|
|
189
|
+
# Array into its inspect, which reported a CALLER's bug as the USER's
|
|
190
|
+
# invocation mistake at exit 2.
|
|
191
|
+
def examine(dest)
|
|
192
|
+
name = File.path(dest)
|
|
193
|
+
raise InvocationError, "output path is empty" if name.empty?
|
|
194
|
+
raise InvocationError, "output path names a directory: #{name}" if name.end_with?(File::SEPARATOR)
|
|
195
|
+
|
|
196
|
+
parent = File.dirname(name)
|
|
197
|
+
raise InvocationError, "output directory is not a directory: #{parent}" unless directory_stat(parent).directory?
|
|
198
|
+
|
|
199
|
+
entry = entry_at(dest)
|
|
200
|
+
return if entry.nil?
|
|
201
|
+
|
|
202
|
+
raise InvocationError, "output path is a directory: #{dest}" if entry.directory?
|
|
203
|
+
raise InvocationError, "output file exists: #{dest} (use --force to replace it)" unless @force
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# THREE distinguishable outcomes, not two. File.directory? cannot do
|
|
207
|
+
# this: it swallows EACCES and returns false, so an unreadable
|
|
208
|
+
# directory that does exist was reported as missing, sending the user
|
|
209
|
+
# to create something already there.
|
|
210
|
+
def directory_stat(dir)
|
|
211
|
+
File.stat(dir)
|
|
212
|
+
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::ELOOP
|
|
213
|
+
raise InvocationError, "output directory does not exist: #{dir}"
|
|
214
|
+
rescue SystemCallError => e
|
|
215
|
+
raise InvocationError, "output directory cannot be examined: #{dir} (#{e.class})"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# lstat, not exist?: a dangling symlink occupies the name and is
|
|
219
|
+
# refused by both File.link and an EXCL open, while exist? calls it
|
|
220
|
+
# absent. ENOENT alone means absent; every other errno means the name
|
|
221
|
+
# cannot be examined. A parent at 0644 is statable but not
|
|
222
|
+
# searchable, so the parent check passes and this raises EACCES --
|
|
223
|
+
# the second of two reachable permission routes, and it answers the
|
|
224
|
+
# same way as the first rather than falling through to a bare errno.
|
|
225
|
+
def entry_at(dest)
|
|
226
|
+
File.lstat(dest)
|
|
227
|
+
rescue Errno::ENOENT
|
|
228
|
+
nil
|
|
229
|
+
rescue SystemCallError => e
|
|
230
|
+
raise InvocationError, "output path cannot be examined: #{dest} (#{e.class})"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def refuse_sources(keys)
|
|
234
|
+
by_key = @sources.to_h { |source| [Naming.key_for(source), source] }
|
|
235
|
+
keys.each do |dest, key|
|
|
236
|
+
source = by_key[key]
|
|
237
|
+
next unless source
|
|
238
|
+
|
|
239
|
+
raise InvocationError, "output would overwrite an input: #{dest} (same file as #{source})"
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Pass 1, by inode. Catches every destination that ALREADY EXISTS --
|
|
244
|
+
# hardlinks, symlinks to one file, case aliases the filesystem itself
|
|
245
|
+
# resolves -- plus exact string repeats among those that do not.
|
|
246
|
+
# Both passes are required: this one misses Logo.svg/logo.svg before
|
|
247
|
+
# either exists, and pass 2 misses hardlinked existing destinations.
|
|
248
|
+
def refuse_duplicates(keys)
|
|
249
|
+
keys.group_by { |_dest, key| key }.each_value do |group|
|
|
250
|
+
next if group.size == 1
|
|
251
|
+
|
|
252
|
+
raise InvocationError, "two outputs are the same file: #{group.map(&:first).join(", ")}"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Pass 2, by NORMALIZED spelling. UNCONDITIONAL -- no probe is
|
|
257
|
+
# consulted and none is possible: the case probe answers a question
|
|
258
|
+
# about CASE, and a pair that differs only in Unicode spelling is not
|
|
259
|
+
# distinguished by case at all. Collapsing this into the fold pass
|
|
260
|
+
# (pass 3) and letting one probe verdict settle both is R-10: it let
|
|
261
|
+
# a file be silently overwritten on a case-sensitive, normalizing
|
|
262
|
+
# filesystem.
|
|
263
|
+
def refuse_normalization_collisions(keys)
|
|
264
|
+
grouped_by(keys) { |name| Naming.normalize(name) }.each_value do |group|
|
|
265
|
+
next if group.size == 1
|
|
266
|
+
|
|
267
|
+
raise InvocationError,
|
|
268
|
+
"two outputs are the same name in different Unicode spellings: " \
|
|
269
|
+
"#{group.map(&:first).join(", ")}"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def refuse_fold_collisions(keys)
|
|
274
|
+
fold_groups(keys).each do |(dir, _folded), group|
|
|
275
|
+
next if group.size == 1
|
|
276
|
+
|
|
277
|
+
refuse_group(dir, group.map(&:first))
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# The dirname comes from the KEY, which `key_for` has already
|
|
282
|
+
# realpath-resolved -- NOT from the raw destination. Grouping by the
|
|
283
|
+
# raw dirname makes both passes miss the same file: out/Q.svg and
|
|
284
|
+
# linkdir/q.svg where linkdir -> out land in different groups, and
|
|
285
|
+
# the second write destroys the first.
|
|
286
|
+
#
|
|
287
|
+
# No filtering beyond `not_on_disk`: pass 2 already refused the whole
|
|
288
|
+
# batch on any normalization collision, so everything reaching here
|
|
289
|
+
# is normalization-distinct by construction.
|
|
290
|
+
def fold_groups(keys)
|
|
291
|
+
grouped_by(keys) { |name| Naming.fold(name) }
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Shared between pass 2 and pass 3 (fold_groups): filters to entries
|
|
295
|
+
# not yet on disk, then groups by [resolved dirname, transformed
|
|
296
|
+
# basename]. The transform -- normalize for pass 2, fold for pass 3
|
|
297
|
+
# -- is the one thing that differs between them, so it is the one
|
|
298
|
+
# thing left to the caller.
|
|
299
|
+
def grouped_by(keys)
|
|
300
|
+
not_on_disk(keys).group_by { |_dest, key| [File.dirname(key), yield(File.basename(key))] }
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# A String key means File.stat did not resolve, which is what "not
|
|
304
|
+
# yet on disk" means here.
|
|
305
|
+
def not_on_disk(keys)
|
|
306
|
+
keys.select { |_dest, key| key.is_a?(String) }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# fetch with a block, never `||=`: `measure` returns FALSE on a
|
|
310
|
+
# case-sensitive volume, so `||=` would re-probe on every group there
|
|
311
|
+
# -- which is the very bug this memo removes, wearing the shape of
|
|
312
|
+
# its own fix. Measured before the memo: 4 colliding groups in one
|
|
313
|
+
# directory produced 4 probe files on a case-sensitive volume and 1
|
|
314
|
+
# on a folding one, because a folding volume refuses at the first.
|
|
315
|
+
def refuse_group(dir, names)
|
|
316
|
+
verdict = @probed.fetch(dir) { @probed[dir] = CaseProbe.measure(dir) }
|
|
317
|
+
if verdict == CaseProbe::UNDECIDABLE
|
|
318
|
+
raise InvocationError,
|
|
319
|
+
"cannot determine whether #{dir} distinguishes case, so refusing: #{names.join(", ")}"
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
raise InvocationError, "two outputs collide on a case-insensitive filesystem: #{names.join(", ")}" if verdict
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Staging, publishing, the mode, and cleanup. Publisher rather than
|
|
327
|
+
# Stage: it does four things and only one of them is staging.
|
|
328
|
+
class Publisher
|
|
329
|
+
def initialize(path, force: false)
|
|
330
|
+
@path = path
|
|
331
|
+
@force = force
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def publish(bytes)
|
|
335
|
+
@replaced_mode = @force ? mode_of_replaced : nil
|
|
336
|
+
write_stage(bytes)
|
|
337
|
+
publish_stage
|
|
338
|
+
ensure
|
|
339
|
+
# rename consumes the stage and clears the name, so this does
|
|
340
|
+
# nothing there. link does not, so on that path this IS the "then
|
|
341
|
+
# unlink the stage" step rather than a failure path. It runs on
|
|
342
|
+
# success too, which is why the name is cleared rather than
|
|
343
|
+
# unlinked unconditionally.
|
|
344
|
+
File.unlink(@staged) if @staged
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
private
|
|
348
|
+
|
|
349
|
+
# The stage name is recorded INSIDE the block, so it is recorded only
|
|
350
|
+
# after the EXCL open has returned: armed before it, an EEXIST clash
|
|
351
|
+
# on the stage name would make the cleanup unlink a file this Writer
|
|
352
|
+
# never created. Closing the block flushes, which is what makes a
|
|
353
|
+
# truncated publish structurally impossible -- an unflushed stage
|
|
354
|
+
# publishes an EMPTY file for any payload under 8 KiB. Do not
|
|
355
|
+
# simplify this to the non-block form; Style/FileOpen forbids it too.
|
|
356
|
+
def write_stage(bytes)
|
|
357
|
+
path = File.join(File.dirname(@path), stage_basename)
|
|
358
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL, stage_mode) do |file|
|
|
359
|
+
@staged = path
|
|
360
|
+
file.binmode
|
|
361
|
+
file.write(bytes)
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def stage_basename
|
|
366
|
+
".#{File.basename(@path)}.claricle-#{Process.pid}-#{Random.urandom(8).unpack1("H*")}"
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Restrictive-create-then-chmod, and only when something is being
|
|
370
|
+
# replaced. On the force path the stage briefly holds the replacement
|
|
371
|
+
# bytes, so creating it 0666 left them world-readable while the file
|
|
372
|
+
# being replaced was private. Creating it AT the destination's mode
|
|
373
|
+
# does not fix that: umask strips bits, so 0666 becomes 0644 and a
|
|
374
|
+
# chmod is still needed. Staging 0600 and chmod'ing up is the safe
|
|
375
|
+
# direction; the window is never wider than the final state.
|
|
376
|
+
#
|
|
377
|
+
# A no-force publish creates a file that did not exist, so there is
|
|
378
|
+
# nothing to disclose and no window. It stages 0666 & ~umask, which
|
|
379
|
+
# is also what keeps the published mode independent of `force`
|
|
380
|
+
# without reading the umask.
|
|
381
|
+
def stage_mode
|
|
382
|
+
@replaced_mode ? 0o600 : 0o666
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def publish_stage
|
|
386
|
+
return link_stage unless @force
|
|
387
|
+
|
|
388
|
+
File.chmod(@replaced_mode, @staged) if @replaced_mode
|
|
389
|
+
File.rename(@staged, @path)
|
|
390
|
+
@staged = nil
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# EEXIST here is genuinely exit 4 becoming exit 2: a destination that
|
|
394
|
+
# appeared after preflight is still the user's argument being wrong.
|
|
395
|
+
# link, not an EXCL open on the destination, because link publishes
|
|
396
|
+
# the name only once the content is complete.
|
|
397
|
+
def link_stage
|
|
398
|
+
File.link(@staged, @path)
|
|
399
|
+
rescue Errno::EEXIST
|
|
400
|
+
raise InvocationError, "output file exists: #{@path} (use --force to replace it)"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# lstat decides the TYPE, so a symlink is not "file" and the
|
|
404
|
+
# published file never inherits the symlink target's mode; stat reads
|
|
405
|
+
# the mode. Regular files only, and & 0o777 rather than & 0o7777, or
|
|
406
|
+
# a setuid bit survives onto content claricle entirely replaced.
|
|
407
|
+
#
|
|
408
|
+
# rename gives the destination the stage's mode, which is why this
|
|
409
|
+
# exists at all. It also gives the destination the stage's OWNER, so
|
|
410
|
+
# replacing another user's file changes its owner -- inherent to the
|
|
411
|
+
# stage-and-rename the card mandates, not something this can fix.
|
|
412
|
+
def mode_of_replaced
|
|
413
|
+
return nil unless File.lstat(@path).ftype == "file"
|
|
414
|
+
|
|
415
|
+
File.stat(@path).mode & 0o777
|
|
416
|
+
rescue SystemCallError
|
|
417
|
+
nil
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
private_constant :Naming, :CaseProbe, :Preflight, :Publisher
|
|
422
|
+
|
|
423
|
+
# sources: is required rather than defaulted. Defaulting it defaults
|
|
424
|
+
# away the strongest rule the card states, and image.rb refuses rather
|
|
425
|
+
# than defaults in the same position.
|
|
426
|
+
def initialize(destinations, sources:, stdout: $stdout, force: false)
|
|
427
|
+
@stdout = stdout
|
|
428
|
+
@force = force
|
|
429
|
+
@planned = destinations.reject { |dest| stdout_destination?(dest) }
|
|
430
|
+
@planned_names = @planned.map { |dest| File.path(dest) }
|
|
431
|
+
Preflight.new(@planned, sources: sources, force: force).check
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Returns the destination exactly as the caller spelled it, or nil for
|
|
435
|
+
# stdout. The canonical form exists only to compare names: `Preflight`
|
|
436
|
+
# already normalizes with `File.path` (Pathname/String both answer it),
|
|
437
|
+
# so a destination planned as one and written as the other -- or a
|
|
438
|
+
# `Pathname.new("-")` standing in for `"-"` -- compares equal here too,
|
|
439
|
+
# rather than raising a false "not preflighted".
|
|
440
|
+
def write(bytes, to:)
|
|
441
|
+
return write_stdout(bytes) if stdout_destination?(to)
|
|
442
|
+
|
|
443
|
+
unless @planned_names.include?(File.path(to))
|
|
444
|
+
raise ArgumentError, "destination was not preflighted: #{to.inspect}"
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
Publisher.new(to, force: @force).publish(bytes)
|
|
448
|
+
to
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
private
|
|
452
|
+
|
|
453
|
+
def stdout_destination?(dest)
|
|
454
|
+
File.path(dest) == STDOUT_DESTINATION
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Never puts, which appends a newline to bytes that do not already end
|
|
458
|
+
# in one and corrupts binary output. binmode first, or a stdout with an
|
|
459
|
+
# encoding set raises Encoding::UndefinedConversionError on real bytes.
|
|
460
|
+
def write_stdout(bytes)
|
|
461
|
+
@stdout.binmode
|
|
462
|
+
@stdout.write(bytes)
|
|
463
|
+
nil
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
private_constant :Writer
|
|
468
|
+
end
|