Module: SafeImage::Remote

Defined in:
lib/safe_image/remote.rb

Constant Summary collapse

DEFAULT_MAX_BYTES =
20 * 1024 * 1024
DEFAULT_MAX_REDIRECTS =
3
DEFAULT_OPEN_TIMEOUT =
5
DEFAULT_READ_TIMEOUT =
10
DEFAULT_TOTAL_TIMEOUT =
30
DEFAULT_ALLOWED_PORTS =
[80, 443].freeze
USER_AGENT =
"safe_image/#{VERSION}".freeze
SAFE_CROSS_ORIGIN_REDIRECT_HEADERS =
%w[accept accept-encoding user-agent].freeze
SAFE_INITIAL_REQUEST_HEADERS =
SAFE_CROSS_ORIGIN_REDIRECT_HEADERS
FORBIDDEN_REQUEST_HEADERS =
%w[
  host connection keep-alive proxy-authenticate proxy-authorization
  proxy-connection te trailer transfer-encoding upgrade
].freeze
CONTENT_TYPE_EXTENSIONS =
{
  "image/jpeg" => ".jpg",
  "image/jpg" => ".jpg",
  "image/png" => ".png",
  "image/gif" => ".gif",
  "image/webp" => ".webp",
  "image/heic" => ".heic",
  "image/heif" => ".heif",
  "image/avif" => ".avif",
  "image/x-icon" => ".ico",
  "image/vnd.microsoft.icon" => ".ico",
  "image/jxl" => ".jxl",
  "image/svg+xml" => ".svg"
}.freeze
EXTENSIONS =
%w[.jpg .jpeg .png .gif .webp .heic .heif .avif .ico .jxl .svg].freeze
SIGNATURES =

First-bytes signatures per downloaded extension, checked as soon as the first SIGNATURE_HEAD_BYTES of the body arrive so an obviously mislabeled response is dropped without downloading the rest. Each entry lists alternative candidates; a candidate is a list of [offset, bytes] pairs that must all match. The check rejects only on a definite mismatch of every candidate against fully-available bytes, so it can never reject an image the configured backend could decode — decoders sniff these same magic bytes to pick a loader. SVG has no usable signature and is exempt.

{
  ".jpg" => [[[0, "\xFF\xD8\xFF".b]]],
  ".jpeg" => [[[0, "\xFF\xD8\xFF".b]]],
  ".png" => [[[0, "\x89PNG\r\n\x1A\n".b]]],
  ".gif" => [[[0, "GIF8".b]]],
  ".webp" => [[[0, "RIFF".b], [8, "WEBP".b]]],
  ".ico" => [[[0, "\x00\x00\x01\x00".b]]],
  ".heic" => [[[4, "ftyp".b]]],
  ".heif" => [[[4, "ftyp".b]]],
  ".avif" => [[[4, "ftyp".b]]],
  ".jxl" => [[[0, "\xFF\x0A".b]], [[0, "\x00\x00\x00\x0CJXL \r\n\x87\n".b]]]
}.freeze
SIGNATURE_HEAD_BYTES =
12
PREFIX_PROBE_INITIAL_BYTES =

The metadata helpers probe the partially-downloaded file at these growing byte thresholds and abort the transfer once the answer is stable, instead of always downloading up to max_bytes.

64 * 1024
PREFIX_PROBE_GROWTH_FACTOR =
4
PREFIX_PROBE_EXTENSIONS =

SVG is excluded from prefix probing: SvgMetadata enforces a total-size cap (MAX_SVG_BYTES) that probing a prefix would bypass, and remote SVGs are small enough that downloading them fully costs little.

(EXTENSIONS - [".svg"]).freeze
CONTINUE_DOWNLOAD =

Sentinel a metadata_fetch block returns when the prefix parsed but its answer could still change with more data (e.g. “not animated”, which a truncated file can report for an animated one).

Object.new.freeze
BLOCKED_IP_RANGES =
[
  # IPv4 special-use / non-public ranges. Default remote fetching is for
  # public Internet images only; callers probing trusted internal URLs must
  # opt in with allow_private: true.
  "0.0.0.0/8",          # current network
  "10.0.0.0/8",         # RFC1918 private-use
  "100.64.0.0/10",      # RFC6598 carrier-grade NAT
  "127.0.0.0/8",        # loopback
  "169.254.0.0/16",     # RFC3927 link-local
  "172.16.0.0/12",      # RFC1918 private-use
  "192.0.0.0/24",       # IETF protocol assignments
  "192.0.2.0/24",       # TEST-NET-1
  "192.31.196.0/24",    # AS112-v4
  "192.52.193.0/24",    # AMT
  "192.168.0.0/16",     # RFC1918 private-use
  "192.175.48.0/24",    # direct delegation AS112 service
  "198.18.0.0/15",      # benchmark testing
  "198.51.100.0/24",    # TEST-NET-2
  "203.0.113.0/24",     # TEST-NET-3
  "224.0.0.0/4",        # multicast
  "240.0.0.0/4",        # reserved / future-use
  "255.255.255.255/32", # limited broadcast

  # IPv6 special-use / non-public ranges.
  "::/128",             # unspecified
  "::1/128",            # loopback
  "::/96",              # deprecated IPv4-compatible IPv6
  "::ffff:0:0/96",      # IPv4-mapped IPv6
  "64:ff9b::/96",       # well-known NAT64 prefix
  "64:ff9b:1::/48",     # local-use NAT64 prefix
  "100::/64",           # discard-only prefix
  "2001::/23",          # IETF protocol assignments, incl. Teredo/benchmarking
  "2001:db8::/32",      # documentation
  "2002::/16",          # 6to4
  "fc00::/7",           # unique local address
  "fe80::/10",          # link-local unicast
  "ff00::/8"            # multicast
].map { |range| IPAddr.new(range) }.freeze

Class Method Summary collapse

Class Method Details

.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) ⇒ Boolean

Returns:

  • (Boolean)


201
202
203
204
205
206
207
208
# File 'lib/safe_image/remote.rb', line 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)
  (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|
    answer = SafeImage.animated?(path, max_pixels: max_pixels)
    next CONTINUE_DOWNLOAD if !eof && answer != true

    answer
  end
end

.blocked_ip?(ip) ⇒ Boolean

Returns:

  • (Boolean)


428
429
430
# File 'lib/safe_image/remote.rb', line 428

def blocked_ip?(ip)
  BLOCKED_IP_RANGES.any? { |range| range.include?(ip) }
end

.check_deadline!(started_at, total_timeout) ⇒ Object

Raises:



453
454
455
456
# File 'lib/safe_image/remote.rb', line 453

def check_deadline!(started_at, total_timeout)
  return unless total_timeout
  raise Error, "remote image request timed out" if monotonic_time - started_at > total_timeout
end

.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) ⇒ Object



210
211
212
213
214
# File 'lib/safe_image/remote.rb', line 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)
  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|
    SafeImage.dominant_color(path, max_pixels: max_pixels)
  end
end

.extension_for(uri, content_type) ⇒ Object



462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
# File 'lib/safe_image/remote.rb', line 462

def extension_for(uri, content_type)
  content_ext = CONTENT_TYPE_EXTENSIONS[content_type]
  raise UnsupportedFormatError, "remote image has unsupported or missing content type: #{content_type.inspect}" unless content_ext

  ext = File.extname(uri.path).downcase
  if EXTENSIONS.include?(ext)
    normalized_ext = ext == ".jpeg" ? ".jpg" : ext
    normalized_content_ext = content_ext == ".jpeg" ? ".jpg" : content_ext
    unless normalized_ext == normalized_content_ext
      raise UnsupportedFormatError, "remote image extension #{ext.inspect} does not match content type #{content_type.inspect}"
    end
    return ext
  end

  content_ext
end

.fetch(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: {}) ⇒ Object



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/safe_image/remote.rb', line 129

def fetch(
  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: {}
)
  uri = parse_uri(url)
  started_at = monotonic_time

  Tempfile.create(["safe-image-remote", ".bin"], binmode: true) do |file|
    response = request(
      uri,
      io: file,
      max_bytes: max_bytes,
      max_redirects: max_redirects,
      open_timeout: open_timeout,
      read_timeout: read_timeout,
      total_timeout: total_timeout,
      started_at: started_at,
      allow_private: allow_private,
      allowed_ports: allowed_ports,
      headers: headers
    )
    file.flush

    ext = response.fetch(:ext)
    path = file.path
    if File.extname(path) != ext
      renamed = path.sub(/\.bin\z/, ext)
      FileUtils.mv(path, renamed)
      begin
        validate_downloaded_image!(renamed, ext)
        yield renamed
      ensure
        FileUtils.rm_f(renamed)
      end
    else
      validate_downloaded_image!(path, ext)
      yield path
    end
  end
end

.filtered_headers(headers) ⇒ Object



432
433
434
# File 'lib/safe_image/remote.rb', line 432

def filtered_headers(headers)
  headers.reject { |key, _| FORBIDDEN_REQUEST_HEADERS.include?(key.to_s.downcase) }
end

.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) ⇒ Object



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/safe_image/remote.rb', line 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)
  (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|
    result = SafeImage.info(path, max_pixels: max_pixels, animated: animated, orientation: orientation)
    # A truncated file can undercount frames but never overcount, so
    # "animated" is final as soon as it is true; "not animated" is only
    # provable from the complete file. Type, dimensions and orientation
    # come from the header the successful probe just parsed, so they
    # cannot change with more data.
    if animated && result.animated != true && !eof
      CONTINUE_DOWNLOAD
    else
      result
    end
  end
end

.initial_headers(headers) ⇒ Object



436
437
438
# File 'lib/safe_image/remote.rb', line 436

def initial_headers(headers)
  filtered_headers(headers).select { |key, _| SAFE_INITIAL_REQUEST_HEADERS.include?(key.to_s.downcase) }
end

.metadata_fetch(url, max_bytes:, max_redirects:, open_timeout:, read_timeout:, total_timeout:, allow_private:, allowed_ports:, headers:, &compute) ⇒ Object

Single-GET download that re-attempts the local metadata probe as bytes arrive (at PREFIX_PROBE_INITIAL_BYTES, then growing by PREFIX_PROBE_GROWTH_FACTOR) and aborts the transfer as soon as the block’s answer is final. The block receives (path, eof) and must return CONTINUE_DOWNLOAD while its answer could still change with more data.

Safety contract: any error from a pre-EOF probe means “not enough bytes yet” — it is swallowed and the download continues, so the complete file always gets the last word with exactly the validation and error behaviour of the full-download path (validate_downloaded_image! plus an un-rescued final probe). A file that never early-exits is handled byte-for-byte like Remote.fetch handles it.



228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
# File 'lib/safe_image/remote.rb', line 228

def (url, max_bytes:, max_redirects:, open_timeout:, read_timeout:, total_timeout:, allow_private:, allowed_ports:, headers:, &compute)
  uri = parse_uri(url)
  started_at = monotonic_time

  Tempfile.create(["safe-image-remote", ".bin"], binmode: true) do |file|
    original_path = file.path
    path = original_path
    ext = nil
    next_probe_at = PREFIX_PROBE_INITIAL_BYTES

    begin
      early = catch(:metadata_answer) do
        request(
          uri,
          io: file,
          max_bytes: max_bytes,
          max_redirects: max_redirects,
          open_timeout: open_timeout,
          read_timeout: read_timeout,
          total_timeout: total_timeout,
          started_at: started_at,
          allow_private: allow_private,
          allowed_ports: allowed_ports,
          headers: headers,
          # The extension is known before the body: give the tempfile its
          # final name up front so probes dispatch on the right loader.
          on_headers: ->(response_ext) do
            ext = response_ext
            renamed = original_path.sub(/\.bin\z/, ext)
            FileUtils.mv(original_path, renamed)
            path = renamed
          end,
          on_progress: ->(bytes) do
            next unless PREFIX_PROBE_EXTENSIONS.include?(ext)
            next if bytes < next_probe_at

            next_probe_at *= PREFIX_PROBE_GROWTH_FACTOR while bytes >= next_probe_at
            file.flush
            answer =
              begin
                compute.call(path, false)
              rescue StandardError
                CONTINUE_DOWNLOAD
              end
            throw :metadata_answer, [answer] unless CONTINUE_DOWNLOAD.equal?(answer)
          end
        )
        nil
      end

      if early
        early.first
      else
        file.flush
        validate_downloaded_image!(path, ext)
        compute.call(path, true)
      end
    ensure
      FileUtils.rm_f(path) unless path == original_path
    end
  end
end

.monotonic_timeObject



458
459
460
# File 'lib/safe_image/remote.rb', line 458

def monotonic_time
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
end

.parse_uri(url) ⇒ Object



393
394
395
396
397
398
399
400
# File 'lib/safe_image/remote.rb', line 393

def parse_uri(url)
  uri = URI.parse(url.to_s)
  raise ArgumentError, "remote image URL must be http or https" unless %w[http https].include?(uri.scheme)
  raise ArgumentError, "remote image URL must include a host" if uri.host.to_s.empty?
  uri
rescue URI::InvalidURIError => e
  raise ArgumentError, "invalid remote image URL: #{e.message}"
end

.redirect_headers(headers, from:, to:) ⇒ Object



440
441
442
443
444
445
# File 'lib/safe_image/remote.rb', line 440

def redirect_headers(headers, from:, to:)
  headers = filtered_headers(headers)
  return headers if same_origin?(from, to)

  headers.select { |key, _| SAFE_CROSS_ORIGIN_REDIRECT_HEADERS.include?(key.to_s.downcase) }
end

.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) ⇒ Object

Raises:

  • (ArgumentError)


291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
# File 'lib/safe_image/remote.rb', line 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)
  require "net/http"
  raise ArgumentError, "too many redirects" if max_redirects < 0
  check_deadline!(started_at, total_timeout)
  ipaddr = validate_uri!(uri, allow_private: allow_private, allowed_ports: allowed_ports)

  http = Net::HTTP.new(uri.host, uri.port, nil)
  http.ipaddr = ipaddr if ipaddr
  http.use_ssl = uri.scheme == "https"
  http.open_timeout = open_timeout
  http.read_timeout = read_timeout

  request = Net::HTTP::Get.new(uri)
  request["User-Agent"] = USER_AGENT
  request["Accept"] = "image/*,*/*;q=0.1"
  request["Accept-Encoding"] = "identity"
  initial_headers(headers).each { |key, value| request[key.to_s] = value.to_s }

  bytes = 0
  content_type = nil
  ext = nil

  http.request(request) do |response|
    check_deadline!(started_at, total_timeout)

    case response
    when Net::HTTPRedirection
      location = response["location"] or raise Error, "redirect without Location"
      redirected = parse_uri(uri.merge(location).to_s)
      if uri.scheme == "https" && redirected.scheme == "http"
        raise UnsafePathError, "refusing HTTPS to HTTP redirect"
      end
      return request(
        redirected,
        io: io,
        max_bytes: max_bytes,
        max_redirects: max_redirects - 1,
        open_timeout: open_timeout,
        read_timeout: read_timeout,
        total_timeout: total_timeout,
        started_at: started_at,
        allow_private: allow_private,
        allowed_ports: allowed_ports,
        headers: redirect_headers(headers, from: uri, to: redirected),
        on_headers: on_headers,
        on_progress: on_progress
      )
    when Net::HTTPSuccess
      content_length = response["content-length"].to_i
      raise LimitError, "remote image exceeds #{max_bytes} bytes" if content_length > max_bytes

      content_type = response["content-type"].to_s.split(";", 2).first.to_s.downcase
      # Everything the content-type and extension-agreement checks need is
      # in the headers: reject unsupported or mismatched responses before
      # reading a single body byte.
      ext = extension_for(uri, content_type)
      on_headers&.call(ext)

      head = "".b
      head_checked = false
      response.read_body do |chunk|
        check_deadline!(started_at, total_timeout)
        bytes += chunk.bytesize
        raise LimitError, "remote image exceeds #{max_bytes} bytes" if bytes > max_bytes
        unless head_checked
          head << chunk
          if head.bytesize >= SIGNATURE_HEAD_BYTES
            verify_signature!(ext, head)
            head_checked = true
            head = nil
          end
        end
        io.write(chunk)
        on_progress&.call(bytes)
      end
      verify_signature!(ext, head) if !head_checked && !head.empty?
    else
      raise Error, "remote image request failed: HTTP #{response.code}"
    end
  end

  { uri: uri, content_type: content_type, ext: ext, bytes: bytes }
end

.same_origin?(a, b) ⇒ Boolean

Returns:

  • (Boolean)


447
448
449
450
451
# File 'lib/safe_image/remote.rb', line 447

def same_origin?(a, b)
  a.scheme.to_s.downcase == b.scheme.to_s.downcase &&
    a.host.to_s.downcase == b.host.to_s.downcase &&
    a.port == b.port
end

.size(url, **kwargs) ⇒ Object



193
194
195
# File 'lib/safe_image/remote.rb', line 193

def size(url, **kwargs)
  info(url, **kwargs).size
end

.type(url, **kwargs) ⇒ Object



197
198
199
# File 'lib/safe_image/remote.rb', line 197

def type(url, **kwargs)
  info(url, **kwargs).type
end

.validate_downloaded_image!(path, ext) ⇒ Object



479
480
481
482
483
484
485
# File 'lib/safe_image/remote.rb', line 479

def validate_downloaded_image!(path, ext)
  if ext == ".svg"
    SvgMetadata.probe(path)
  else
    SafeImage.probe(path)
  end
end

.validate_uri!(uri, allow_private:, allowed_ports: DEFAULT_ALLOWED_PORTS) ⇒ Object

Raises:



402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
# File 'lib/safe_image/remote.rb', line 402

def validate_uri!(uri, allow_private:, allowed_ports: DEFAULT_ALLOWED_PORTS)
  unless allow_private || allowed_ports.nil? || allowed_ports.include?(uri.port)
    raise UnsafePathError, "remote image URL uses a disallowed port"
  end
  return nil if allow_private

  require "resolv"
  resolver = Resolv::DNS.new
  resolver.timeouts = [2, 2]
  addresses = resolver.getaddresses(uri.host).map(&:to_s)
  raise UnsafePathError, "remote image host did not resolve" if addresses.empty?

  addresses.each do |address|
    ip = IPAddr.new(address)
    if blocked_ip?(ip)
      raise UnsafePathError, "remote image host resolves to a non-public address"
    end
  end

  # Pin the socket to a vetted address so validation and connection cannot
  # observe different DNS answers. Prefer IPv4 first for compatibility with
  # common hosts, but either family is fine because every address above was
  # checked.
  addresses.sort_by { |address| address.include?(":") ? 1 : 0 }.first
end

.verify_signature!(ext, head) ⇒ Object

Rejects a body whose first bytes definitively cannot belong to the format the response claimed. Formats without a fixed signature (SVG) and bytes not yet downloaded are never grounds for rejection.

Raises:



378
379
380
381
382
383
384
385
386
387
388
389
390
391
# File 'lib/safe_image/remote.rb', line 378

def verify_signature!(ext, head)
  candidates = SIGNATURES[ext]
  return unless candidates

  compatible = candidates.any? do |candidate|
    candidate.all? do |offset, bytes|
      slice = head.byteslice(offset, bytes.bytesize).to_s
      slice.empty? || slice == bytes.byteslice(0, slice.bytesize)
    end
  end
  return if compatible

  raise InvalidImageError, "remote image first bytes do not match #{ext.delete_prefix(".")} signature"
end