safe_image 0.3.0 → 0.5.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -12
  3. data/README.md +140 -287
  4. data/SECURITY.md +22 -11
  5. data/docs/architecture.md +63 -0
  6. data/ext/safe_image_vips_helper/extconf.rb +43 -0
  7. data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
  8. data/lib/safe_image/api/metadata.rb +85 -0
  9. data/lib/safe_image/api/transform.rb +152 -0
  10. data/lib/safe_image/backend_label.rb +24 -0
  11. data/lib/safe_image/formats.rb +96 -0
  12. data/lib/safe_image/ico.rb +42 -40
  13. data/lib/safe_image/image_magick_backend.rb +219 -162
  14. data/lib/safe_image/jpegli_backend.rb +64 -44
  15. data/lib/safe_image/metadata_operations.rb +155 -0
  16. data/lib/safe_image/native.rb +96 -290
  17. data/lib/safe_image/native_helper.rb +281 -0
  18. data/lib/safe_image/operation_backends/base.rb +83 -0
  19. data/lib/safe_image/operation_backends/image_magick.rb +123 -0
  20. data/lib/safe_image/operation_backends/vips.rb +251 -0
  21. data/lib/safe_image/operation_backends.rb +27 -0
  22. data/lib/safe_image/operation_set.rb +22 -0
  23. data/lib/safe_image/optimizer.rb +225 -98
  24. data/lib/safe_image/path_safety.rb +11 -0
  25. data/lib/safe_image/processor.rb +122 -85
  26. data/lib/safe_image/quality_defaults.rb +23 -0
  27. data/lib/safe_image/remote.rb +248 -144
  28. data/lib/safe_image/result.rb +71 -23
  29. data/lib/safe_image/runner.rb +60 -23
  30. data/lib/safe_image/sandbox.rb +44 -218
  31. data/lib/safe_image/staged_output.rb +44 -0
  32. data/lib/safe_image/svg_metadata.rb +74 -69
  33. data/lib/safe_image/transform_operations.rb +139 -0
  34. data/lib/safe_image/version.rb +1 -1
  35. data/lib/safe_image/vips_backend.rb +13 -12
  36. data/lib/safe_image.rb +62 -306
  37. metadata +43 -37
  38. data/lib/safe_image/discourse_compat.rb +0 -441
  39. data/lib/safe_image/svg_css.rb +0 -314
  40. data/lib/safe_image/svg_sanitizer.rb +0 -583
  41. data/lib/safe_image/vips_glue.rb +0 -361
  42. data/lib/safe_image/zygote.rb +0 -619
@@ -7,10 +7,9 @@ require "time"
7
7
  require "tmpdir"
8
8
  require "uri"
9
9
  # net/http and resolv are required lazily inside the methods that fetch, not at
10
- # load time: requiring them pulls in resolv, which (Ruby 3.4+) reads
11
- # /etc/resolv.conf eagerly. The Landlock-sandboxed worker loads this file but
12
- # never performs remote fetches, and on hosts where /etc/resolv.conf is a
13
- # symlink into /run the sandbox denies that read and the worker dies at boot.
10
+ # load time: remote fetches need network access, while sandboxed image-processing
11
+ # child helpers/tools deliberately do not get it. Keep networking libraries off
12
+ # the load path until a caller uses the remote API.
14
13
 
15
14
  module SafeImage
16
15
  module Remote
@@ -27,8 +26,16 @@ module SafeImage
27
26
  SAFE_CROSS_ORIGIN_REDIRECT_HEADERS = %w[accept accept-encoding user-agent].freeze
28
27
  SAFE_INITIAL_REQUEST_HEADERS = SAFE_CROSS_ORIGIN_REDIRECT_HEADERS
29
28
  FORBIDDEN_REQUEST_HEADERS = %w[
30
- host connection keep-alive proxy-authenticate proxy-authorization
31
- proxy-connection te trailer transfer-encoding upgrade
29
+ host
30
+ connection
31
+ keep-alive
32
+ proxy-authenticate
33
+ proxy-authorization
34
+ proxy-connection
35
+ te
36
+ trailer
37
+ transfer-encoding
38
+ upgrade
32
39
  ].freeze
33
40
 
34
41
  CONTENT_TYPE_EXTENSIONS = {
@@ -69,11 +76,13 @@ module SafeImage
69
76
  ".jxl" => [[[0, "\xFF\x0A".b]], [[0, "\x00\x00\x00\x0CJXL \r\n\x87\n".b]]]
70
77
  }.freeze
71
78
 
79
+ # 12 bytes covers the longest fixed magic this gate uses today (container JXL)
80
+ # while still rejecting mismatches before meaningful body download.
72
81
  SIGNATURE_HEAD_BYTES = 12
73
82
 
74
- # The metadata helpers probe the partially-downloaded file at these
75
- # growing byte thresholds and abort the transfer once the answer is
76
- # stable, instead of always downloading up to max_bytes.
83
+ # The metadata helpers probe the partially-downloaded file at these growing
84
+ # byte thresholds: 64KiB catches normal headers, x4 reaches pathological but
85
+ # valid marker chains without too many decoder attempts.
77
86
  PREFIX_PROBE_INITIAL_BYTES = 64 * 1024
78
87
  PREFIX_PROBE_GROWTH_FACTOR = 4
79
88
 
@@ -87,44 +96,44 @@ module SafeImage
87
96
  # truncated file can report for an animated one).
88
97
  CONTINUE_DOWNLOAD = Object.new.freeze
89
98
 
90
- BLOCKED_IP_RANGES = [
91
- # IPv4 special-use / non-public ranges. Default remote fetching is for
92
- # public Internet images only; callers probing trusted internal URLs must
93
- # opt in with allow_private: true.
94
- "0.0.0.0/8", # current network
95
- "10.0.0.0/8", # RFC1918 private-use
96
- "100.64.0.0/10", # RFC6598 carrier-grade NAT
97
- "127.0.0.0/8", # loopback
98
- "169.254.0.0/16", # RFC3927 link-local
99
- "172.16.0.0/12", # RFC1918 private-use
100
- "192.0.0.0/24", # IETF protocol assignments
101
- "192.0.2.0/24", # TEST-NET-1
102
- "192.31.196.0/24", # AS112-v4
103
- "192.52.193.0/24", # AMT
104
- "192.168.0.0/16", # RFC1918 private-use
105
- "192.175.48.0/24", # direct delegation AS112 service
106
- "198.18.0.0/15", # benchmark testing
107
- "198.51.100.0/24", # TEST-NET-2
108
- "203.0.113.0/24", # TEST-NET-3
109
- "224.0.0.0/4", # multicast
110
- "240.0.0.0/4", # reserved / future-use
111
- "255.255.255.255/32", # limited broadcast
112
-
113
- # IPv6 special-use / non-public ranges.
114
- "::/128", # unspecified
115
- "::1/128", # loopback
116
- "::/96", # deprecated IPv4-compatible IPv6
117
- "::ffff:0:0/96", # IPv4-mapped IPv6
118
- "64:ff9b::/96", # well-known NAT64 prefix
119
- "64:ff9b:1::/48", # local-use NAT64 prefix
120
- "100::/64", # discard-only prefix
121
- "2001::/23", # IETF protocol assignments, incl. Teredo/benchmarking
122
- "2001:db8::/32", # documentation
123
- "2002::/16", # 6to4
124
- "fc00::/7", # unique local address
125
- "fe80::/10", # link-local unicast
126
- "ff00::/8" # multicast
127
- ].map { |range| IPAddr.new(range) }.freeze
99
+ BLOCKED_IP_RANGES =
100
+ [
101
+ # IPv4 special-use / non-public ranges. Default remote fetching is for
102
+ # public Internet images only; callers probing trusted internal URLs must
103
+ # opt in with allow_private: true.
104
+ "0.0.0.0/8", # current network
105
+ "10.0.0.0/8", # RFC1918 private-use
106
+ "100.64.0.0/10", # RFC6598 carrier-grade NAT
107
+ "127.0.0.0/8", # loopback
108
+ "169.254.0.0/16", # RFC3927 link-local
109
+ "172.16.0.0/12", # RFC1918 private-use
110
+ "192.0.0.0/24", # IETF protocol assignments
111
+ "192.0.2.0/24", # TEST-NET-1
112
+ "192.31.196.0/24", # AS112-v4
113
+ "192.52.193.0/24", # AMT
114
+ "192.168.0.0/16", # RFC1918 private-use
115
+ "192.175.48.0/24", # direct delegation AS112 service
116
+ "198.18.0.0/15", # benchmark testing
117
+ "198.51.100.0/24", # TEST-NET-2
118
+ "203.0.113.0/24", # TEST-NET-3
119
+ "224.0.0.0/4", # multicast
120
+ "240.0.0.0/4", # reserved / future-use
121
+ "255.255.255.255/32", # limited broadcast
122
+ # IPv6 special-use / non-public ranges.
123
+ "::/128", # unspecified
124
+ "::1/128", # loopback
125
+ "::/96", # deprecated IPv4-compatible IPv6
126
+ "::ffff:0:0/96", # IPv4-mapped IPv6
127
+ "64:ff9b::/96", # well-known NAT64 prefix
128
+ "64:ff9b:1::/48", # local-use NAT64 prefix
129
+ "100::/64", # discard-only prefix
130
+ "2001::/23", # IETF protocol assignments, incl. Teredo/benchmarking
131
+ "2001:db8::/32", # documentation
132
+ "2002::/16", # 6to4
133
+ "fc00::/7", # unique local address
134
+ "fe80::/10", # link-local unicast
135
+ "ff00::/8" # multicast
136
+ ].map { |range| IPAddr.new(range) }.freeze
128
137
 
129
138
  def fetch(
130
139
  url,
@@ -140,20 +149,21 @@ module SafeImage
140
149
  uri = parse_uri(url)
141
150
  started_at = monotonic_time
142
151
 
143
- Tempfile.create(["safe-image-remote", ".bin"], binmode: true) do |file|
144
- response = request(
145
- uri,
146
- io: file,
147
- max_bytes: max_bytes,
148
- max_redirects: max_redirects,
149
- open_timeout: open_timeout,
150
- read_timeout: read_timeout,
151
- total_timeout: total_timeout,
152
- started_at: started_at,
153
- allow_private: allow_private,
154
- allowed_ports: allowed_ports,
155
- headers: headers
156
- )
152
+ Tempfile.create(%w[safe-image-remote .bin], binmode: true) do |file|
153
+ response =
154
+ request(
155
+ uri,
156
+ io: file,
157
+ max_bytes: max_bytes,
158
+ max_redirects: max_redirects,
159
+ open_timeout: open_timeout,
160
+ read_timeout: read_timeout,
161
+ total_timeout: total_timeout,
162
+ started_at: started_at,
163
+ allow_private: allow_private,
164
+ allowed_ports: allowed_ports,
165
+ headers: headers
166
+ )
157
167
  file.flush
158
168
 
159
169
  ext = response.fetch(:ext)
@@ -174,8 +184,31 @@ module SafeImage
174
184
  end
175
185
  end
176
186
 
177
- def info(url, max_bytes: DEFAULT_MAX_BYTES, max_redirects: DEFAULT_MAX_REDIRECTS, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, total_timeout: DEFAULT_TOTAL_TIMEOUT, allow_private: false, allowed_ports: DEFAULT_ALLOWED_PORTS, headers: {}, max_pixels: nil, animated: false, orientation: false)
178
- metadata_fetch(url, max_bytes: max_bytes, max_redirects: max_redirects, open_timeout: open_timeout, read_timeout: read_timeout, total_timeout: total_timeout, allow_private: allow_private, allowed_ports: allowed_ports, headers: headers) do |path, eof|
187
+ def info(
188
+ url,
189
+ max_bytes: DEFAULT_MAX_BYTES,
190
+ max_redirects: DEFAULT_MAX_REDIRECTS,
191
+ open_timeout: DEFAULT_OPEN_TIMEOUT,
192
+ read_timeout: DEFAULT_READ_TIMEOUT,
193
+ total_timeout: DEFAULT_TOTAL_TIMEOUT,
194
+ allow_private: false,
195
+ allowed_ports: DEFAULT_ALLOWED_PORTS,
196
+ headers: {},
197
+ max_pixels: nil,
198
+ animated: false,
199
+ orientation: false
200
+ )
201
+ metadata_fetch(
202
+ url,
203
+ max_bytes: max_bytes,
204
+ max_redirects: max_redirects,
205
+ open_timeout: open_timeout,
206
+ read_timeout: read_timeout,
207
+ total_timeout: total_timeout,
208
+ allow_private: allow_private,
209
+ allowed_ports: allowed_ports,
210
+ headers: headers
211
+ ) do |path, eof|
179
212
  result = SafeImage.info(path, max_pixels: max_pixels, animated: animated, orientation: orientation)
180
213
  # A truncated file can undercount frames but never overcount, so
181
214
  # "animated" is final as soon as it is true; "not animated" is only
@@ -198,8 +231,29 @@ module SafeImage
198
231
  info(url, **kwargs).type
199
232
  end
200
233
 
201
- def animated?(url, max_bytes: DEFAULT_MAX_BYTES, max_redirects: DEFAULT_MAX_REDIRECTS, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, total_timeout: DEFAULT_TOTAL_TIMEOUT, allow_private: false, allowed_ports: DEFAULT_ALLOWED_PORTS, headers: {}, max_pixels: nil)
202
- metadata_fetch(url, max_bytes: max_bytes, max_redirects: max_redirects, open_timeout: open_timeout, read_timeout: read_timeout, total_timeout: total_timeout, allow_private: allow_private, allowed_ports: allowed_ports, headers: headers) do |path, eof|
234
+ def animated?(
235
+ url,
236
+ max_bytes: DEFAULT_MAX_BYTES,
237
+ max_redirects: DEFAULT_MAX_REDIRECTS,
238
+ open_timeout: DEFAULT_OPEN_TIMEOUT,
239
+ read_timeout: DEFAULT_READ_TIMEOUT,
240
+ total_timeout: DEFAULT_TOTAL_TIMEOUT,
241
+ allow_private: false,
242
+ allowed_ports: DEFAULT_ALLOWED_PORTS,
243
+ headers: {},
244
+ max_pixels: nil
245
+ )
246
+ metadata_fetch(
247
+ url,
248
+ max_bytes: max_bytes,
249
+ max_redirects: max_redirects,
250
+ open_timeout: open_timeout,
251
+ read_timeout: read_timeout,
252
+ total_timeout: total_timeout,
253
+ allow_private: allow_private,
254
+ allowed_ports: allowed_ports,
255
+ headers: headers
256
+ ) do |path, eof|
203
257
  answer = SafeImage.animated?(path, max_pixels: max_pixels)
204
258
  next CONTINUE_DOWNLOAD if !eof && answer != true
205
259
 
@@ -207,10 +261,29 @@ module SafeImage
207
261
  end
208
262
  end
209
263
 
210
- def dominant_color(url, max_bytes: DEFAULT_MAX_BYTES, max_redirects: DEFAULT_MAX_REDIRECTS, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, total_timeout: DEFAULT_TOTAL_TIMEOUT, allow_private: false, allowed_ports: DEFAULT_ALLOWED_PORTS, headers: {}, max_pixels: nil)
211
- fetch(url, max_bytes: max_bytes, max_redirects: max_redirects, open_timeout: open_timeout, read_timeout: read_timeout, total_timeout: total_timeout, allow_private: allow_private, allowed_ports: allowed_ports, headers: headers) do |path|
212
- SafeImage.dominant_color(path, max_pixels: max_pixels)
213
- end
264
+ def dominant_color(
265
+ url,
266
+ max_bytes: DEFAULT_MAX_BYTES,
267
+ max_redirects: DEFAULT_MAX_REDIRECTS,
268
+ open_timeout: DEFAULT_OPEN_TIMEOUT,
269
+ read_timeout: DEFAULT_READ_TIMEOUT,
270
+ total_timeout: DEFAULT_TOTAL_TIMEOUT,
271
+ allow_private: false,
272
+ allowed_ports: DEFAULT_ALLOWED_PORTS,
273
+ headers: {},
274
+ max_pixels: nil
275
+ )
276
+ fetch(
277
+ url,
278
+ max_bytes: max_bytes,
279
+ max_redirects: max_redirects,
280
+ open_timeout: open_timeout,
281
+ read_timeout: read_timeout,
282
+ total_timeout: total_timeout,
283
+ allow_private: allow_private,
284
+ allowed_ports: allowed_ports,
285
+ headers: headers
286
+ ) { |path| SafeImage.dominant_color(path, max_pixels: max_pixels) }
214
287
  end
215
288
 
216
289
  # Single-GET download that re-attempts the local metadata probe as bytes
@@ -225,55 +298,67 @@ module SafeImage
225
298
  # behaviour of the full-download path (validate_downloaded_image! plus an
226
299
  # un-rescued final probe). A file that never early-exits is handled
227
300
  # byte-for-byte like Remote.fetch handles it.
228
- def metadata_fetch(url, max_bytes:, max_redirects:, open_timeout:, read_timeout:, total_timeout:, allow_private:, allowed_ports:, headers:, &compute)
301
+ def metadata_fetch(
302
+ url,
303
+ max_bytes:,
304
+ max_redirects:,
305
+ open_timeout:,
306
+ read_timeout:,
307
+ total_timeout:,
308
+ allow_private:,
309
+ allowed_ports:,
310
+ headers:,
311
+ &compute
312
+ )
229
313
  uri = parse_uri(url)
230
314
  started_at = monotonic_time
231
315
 
232
- Tempfile.create(["safe-image-remote", ".bin"], binmode: true) do |file|
316
+ Tempfile.create(%w[safe-image-remote .bin], binmode: true) do |file|
233
317
  original_path = file.path
234
318
  path = original_path
235
319
  ext = nil
236
320
  next_probe_at = PREFIX_PROBE_INITIAL_BYTES
237
321
 
238
322
  begin
239
- early = catch(:metadata_answer) do
240
- request(
241
- uri,
242
- io: file,
243
- max_bytes: max_bytes,
244
- max_redirects: max_redirects,
245
- open_timeout: open_timeout,
246
- read_timeout: read_timeout,
247
- total_timeout: total_timeout,
248
- started_at: started_at,
249
- allow_private: allow_private,
250
- allowed_ports: allowed_ports,
251
- headers: headers,
252
- # The extension is known before the body: give the tempfile its
253
- # final name up front so probes dispatch on the right loader.
254
- on_headers: ->(response_ext) do
255
- ext = response_ext
256
- renamed = original_path.sub(/\.bin\z/, ext)
257
- FileUtils.mv(original_path, renamed)
258
- path = renamed
259
- end,
260
- on_progress: ->(bytes) do
261
- next unless PREFIX_PROBE_EXTENSIONS.include?(ext)
262
- next if bytes < next_probe_at
263
-
264
- next_probe_at *= PREFIX_PROBE_GROWTH_FACTOR while bytes >= next_probe_at
265
- file.flush
266
- answer =
267
- begin
268
- compute.call(path, false)
269
- rescue StandardError
270
- CONTINUE_DOWNLOAD
271
- end
272
- throw :metadata_answer, [answer] unless CONTINUE_DOWNLOAD.equal?(answer)
273
- end
274
- )
275
- nil
276
- end
323
+ early =
324
+ catch(:metadata_answer) do
325
+ request(
326
+ uri,
327
+ io: file,
328
+ max_bytes: max_bytes,
329
+ max_redirects: max_redirects,
330
+ open_timeout: open_timeout,
331
+ read_timeout: read_timeout,
332
+ total_timeout: total_timeout,
333
+ started_at: started_at,
334
+ allow_private: allow_private,
335
+ allowed_ports: allowed_ports,
336
+ headers: headers,
337
+ # The extension is known before the body: give the tempfile its
338
+ # final name up front so probes dispatch on the right loader.
339
+ on_headers: ->(response_ext) do
340
+ ext = response_ext
341
+ renamed = original_path.sub(/\.bin\z/, ext)
342
+ FileUtils.mv(original_path, renamed)
343
+ path = renamed
344
+ end,
345
+ on_progress: ->(bytes) do
346
+ next if PREFIX_PROBE_EXTENSIONS.none? { |candidate| candidate == ext }
347
+ next if bytes < next_probe_at
348
+
349
+ next_probe_at *= PREFIX_PROBE_GROWTH_FACTOR while bytes >= next_probe_at
350
+ file.flush
351
+ answer =
352
+ begin
353
+ compute.call(path, false)
354
+ rescue StandardError
355
+ CONTINUE_DOWNLOAD
356
+ end
357
+ throw :metadata_answer, [answer] unless CONTINUE_DOWNLOAD.equal?(answer)
358
+ end
359
+ )
360
+ nil
361
+ end
277
362
 
278
363
  if early
279
364
  early.first
@@ -288,7 +373,21 @@ module SafeImage
288
373
  end
289
374
  end
290
375
 
291
- def request(uri, io:, max_bytes:, max_redirects:, open_timeout:, read_timeout:, total_timeout:, started_at:, allow_private:, allowed_ports:, headers: {}, on_headers: nil, on_progress: nil)
376
+ def request(
377
+ uri,
378
+ io:,
379
+ max_bytes:,
380
+ max_redirects:,
381
+ open_timeout:,
382
+ read_timeout:,
383
+ total_timeout:,
384
+ started_at:,
385
+ allow_private:,
386
+ allowed_ports:,
387
+ headers: {},
388
+ on_headers: nil,
389
+ on_progress: nil
390
+ )
292
391
  require "net/http"
293
392
  raise ArgumentError, "too many redirects" if max_redirects < 0
294
393
  check_deadline!(started_at, total_timeout)
@@ -320,21 +419,23 @@ module SafeImage
320
419
  if uri.scheme == "https" && redirected.scheme == "http"
321
420
  raise UnsafePathError, "refusing HTTPS to HTTP redirect"
322
421
  end
323
- return request(
324
- redirected,
325
- io: io,
326
- max_bytes: max_bytes,
327
- max_redirects: max_redirects - 1,
328
- open_timeout: open_timeout,
329
- read_timeout: read_timeout,
330
- total_timeout: total_timeout,
331
- started_at: started_at,
332
- allow_private: allow_private,
333
- allowed_ports: allowed_ports,
334
- headers: redirect_headers(headers, from: uri, to: redirected),
335
- on_headers: on_headers,
336
- on_progress: on_progress
337
- )
422
+ redirected_response =
423
+ request(
424
+ redirected,
425
+ io: io,
426
+ max_bytes: max_bytes,
427
+ max_redirects: max_redirects - 1,
428
+ open_timeout: open_timeout,
429
+ read_timeout: read_timeout,
430
+ total_timeout: total_timeout,
431
+ started_at: started_at,
432
+ allow_private: allow_private,
433
+ allowed_ports: allowed_ports,
434
+ headers: redirect_headers(headers, from: uri, to: redirected),
435
+ on_headers: on_headers,
436
+ on_progress: on_progress
437
+ )
438
+ return redirected_response
338
439
  when Net::HTTPSuccess
339
440
  content_length = response["content-length"].to_i
340
441
  raise LimitError, "remote image exceeds #{max_bytes} bytes" if content_length > max_bytes
@@ -379,12 +480,13 @@ module SafeImage
379
480
  candidates = SIGNATURES[ext]
380
481
  return unless candidates
381
482
 
382
- compatible = candidates.any? do |candidate|
383
- candidate.all? do |offset, bytes|
384
- slice = head.byteslice(offset, bytes.bytesize).to_s
385
- slice.empty? || slice == bytes.byteslice(0, slice.bytesize)
483
+ compatible =
484
+ candidates.any? do |candidate|
485
+ candidate.all? do |offset, bytes|
486
+ slice = head.byteslice(offset, bytes.bytesize).to_s
487
+ slice.empty? || slice == bytes.byteslice(0, slice.bytesize)
488
+ end
386
489
  end
387
- end
388
490
  return if compatible
389
491
 
390
492
  raise InvalidImageError, "remote image first bytes do not match #{ext.delete_prefix(".")} signature"
@@ -392,7 +494,9 @@ module SafeImage
392
494
 
393
495
  def parse_uri(url)
394
496
  uri = URI.parse(url.to_s)
395
- raise ArgumentError, "remote image URL must be http or https" unless %w[http https].include?(uri.scheme)
497
+ if %w[http https].none? { |scheme| scheme == uri.scheme }
498
+ raise ArgumentError, "remote image URL must be http or https"
499
+ end
396
500
  raise ArgumentError, "remote image URL must include a host" if uri.host.to_s.empty?
397
501
  uri
398
502
  rescue URI::InvalidURIError => e
@@ -413,9 +517,7 @@ module SafeImage
413
517
 
414
518
  addresses.each do |address|
415
519
  ip = IPAddr.new(address)
416
- if blocked_ip?(ip)
417
- raise UnsafePathError, "remote image host resolves to a non-public address"
418
- end
520
+ raise UnsafePathError, "remote image host resolves to a non-public address" if blocked_ip?(ip)
419
521
  end
420
522
 
421
523
  # Pin the socket to a vetted address so validation and connection cannot
@@ -445,8 +547,7 @@ module SafeImage
445
547
  end
446
548
 
447
549
  def same_origin?(a, b)
448
- a.scheme.to_s.downcase == b.scheme.to_s.downcase &&
449
- a.host.to_s.downcase == b.host.to_s.downcase &&
550
+ a.scheme.to_s.downcase == b.scheme.to_s.downcase && a.host.to_s.downcase == b.host.to_s.downcase &&
450
551
  a.port == b.port
451
552
  end
452
553
 
@@ -461,14 +562,17 @@ module SafeImage
461
562
 
462
563
  def extension_for(uri, content_type)
463
564
  content_ext = CONTENT_TYPE_EXTENSIONS[content_type]
464
- raise UnsupportedFormatError, "remote image has unsupported or missing content type: #{content_type.inspect}" unless content_ext
565
+ unless content_ext
566
+ raise UnsupportedFormatError, "remote image has unsupported or missing content type: #{content_type.inspect}"
567
+ end
465
568
 
466
569
  ext = File.extname(uri.path).downcase
467
570
  if EXTENSIONS.include?(ext)
468
571
  normalized_ext = ext == ".jpeg" ? ".jpg" : ext
469
572
  normalized_content_ext = content_ext == ".jpeg" ? ".jpg" : content_ext
470
573
  unless normalized_ext == normalized_content_ext
471
- raise UnsupportedFormatError, "remote image extension #{ext.inspect} does not match content type #{content_type.inspect}"
574
+ raise UnsupportedFormatError,
575
+ "remote image extension #{ext.inspect} does not match content type #{content_type.inspect}"
472
576
  end
473
577
  return ext
474
578
  end
@@ -476,12 +580,12 @@ module SafeImage
476
580
  content_ext
477
581
  end
478
582
 
479
- def validate_downloaded_image!(path, ext)
480
- if ext == ".svg"
481
- SvgMetadata.probe(path)
482
- else
483
- SafeImage.probe(path)
484
- end
583
+ def validate_downloaded_image!(path, _ext)
584
+ # Always go back through the public local probe path so configured
585
+ # sandboxing and pixel limits apply uniformly. Remote.fetch itself needs
586
+ # network access; once bytes are on disk, validation must re-enter the
587
+ # normal image-processing boundary.
588
+ SafeImage.probe(path)
485
589
  end
486
590
  end
487
591
  end
@@ -1,28 +1,76 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeImage
4
- Info = Data.define(
5
- :path,
6
- :type,
7
- :width,
8
- :height,
9
- :size,
10
- :animated,
11
- :orientation
12
- )
4
+ # Public metadata return value. Optional fields (`animated`, `orientation`) are
5
+ # nil unless the caller opts into the extra probes.
6
+ Info = Data.define(:path, :type, :width, :height, :size, :animated, :orientation)
13
7
 
14
- Result = Data.define(
15
- :input,
16
- :output,
17
- :input_format,
18
- :output_format,
19
- :width,
20
- :height,
21
- :filesize,
22
- :backend,
23
- :duration_ms,
24
- :optimizer
25
- ) do
26
- def success? = true
27
- end
8
+ # Public image-operation return value. `output`/`output_format` are nil for
9
+ # metadata-only probes; image-producing operations always include them. `tier`
10
+ # records the concrete execution tier (for example :native_encode,
11
+ # :jpegtran_lossless or :jpegtran_fallback_reencode) so non-fatal degradation
12
+ # is observable without parsing the backend label.
13
+ Result =
14
+ Data.define(
15
+ :input,
16
+ :output,
17
+ :input_format,
18
+ :output_format,
19
+ :width,
20
+ :height,
21
+ :filesize,
22
+ :backend,
23
+ :duration_ms,
24
+ :optimizer,
25
+ :tier
26
+ ) do
27
+ def self.build(
28
+ input:,
29
+ output: nil,
30
+ input_format:,
31
+ output_format: nil,
32
+ width:,
33
+ height:,
34
+ backend:,
35
+ duration_ms:,
36
+ filesize: nil,
37
+ optimizer: nil,
38
+ encoder: nil,
39
+ tier: nil
40
+ )
41
+ new(
42
+ input: input.to_s,
43
+ output: output&.to_s,
44
+ input_format: input_format,
45
+ output_format: output_format,
46
+ width: width,
47
+ height: height,
48
+ filesize: filesize || result_filesize(input, output),
49
+ backend: BackendLabel.build(backend, encoder: encoder),
50
+ duration_ms: duration_ms,
51
+ optimizer: optimizer,
52
+ tier: tier
53
+ )
54
+ end
55
+
56
+ def self.metadata(input:, input_format:, width:, height:, backend:, duration_ms:)
57
+ build(
58
+ input: input,
59
+ input_format: input_format,
60
+ width: width,
61
+ height: height,
62
+ filesize: File.size(input),
63
+ backend: backend,
64
+ duration_ms: duration_ms,
65
+ tier: :metadata
66
+ )
67
+ end
68
+
69
+ def self.result_filesize(input, output)
70
+ path = output || input
71
+ path ? File.size(path) : nil
72
+ end
73
+
74
+ def success? = true
75
+ end
28
76
  end