Module: SafeImage

Defined in:
lib/safe_image/remote.rb,
lib/safe_image.rb,
lib/safe_image.rb,
lib/safe_image/ico.rb,
lib/safe_image/native.rb,
lib/safe_image/result.rb,
lib/safe_image/runner.rb,
lib/safe_image/zygote.rb,
lib/safe_image/sandbox.rb,
lib/safe_image/svg_css.rb,
lib/safe_image/version.rb,
lib/safe_image/optimizer.rb,
lib/safe_image/processor.rb,
lib/safe_image/vips_glue.rb,
lib/safe_image/path_safety.rb,
lib/safe_image/svg_metadata.rb,
lib/safe_image/vips_backend.rb,
lib/safe_image/svg_sanitizer.rb,
lib/safe_image/jpegli_backend.rb,
lib/safe_image/discourse_compat.rb,
lib/safe_image/image_magick_backend.rb

Overview

symlink into /run the sandbox denies that read and the worker dies at boot.

Defined Under Namespace

Modules: DiscourseCompat, Ico, ImageMagickBackend, JpegliBackend, Native, Optimizer, PathSafety, Remote, Runner, Sandbox, SvgCss, SvgMetadata, SvgSanitizer, VipsBackend, VipsGlue, Zygote Classes: CommandError, Config, Error, Info, InvalidImageError, LimitError, NotConfiguredError, Processor, Result, UnsafePathError, UnsupportedFormatError, VipsUnavailableError, WorkerBroken

Constant Summary collapse

DEFAULT_MAX_PIXELS =

Default decompression-bomb ceiling when configure! is not given an explicit max_pixels. Mirrored in the native binding (SAFE_IMAGE_DEFAULT_MAX_PIXELS) and aligned with the 128MP area limit on the ImageMagick path. Per-call max_pixels: overrides the configured value.

128 * 1024 * 1024
BACKENDS =
%i[vips imagemagick].freeze
VERSION =
"0.3.0"

Class Method Summary collapse

Class Method Details

.animated?(*args, **kwargs) ⇒ Boolean

Returns:

  • (Boolean)


366
367
368
369
370
371
372
# File 'lib/safe_image.rb', line 366

def animated?(*args, **kwargs)
  config
  path = args.first
  return false if path && File.extname(PathSafety.local_path(path)).downcase == ".svg"

  maybe_sandbox(:animated?, args: args, kwargs: kwargs) { DiscourseCompat.animated?(*args, **kwargs) }
end

.configObject



98
99
100
# File 'lib/safe_image.rb', line 98

def config
  @config || raise(NotConfiguredError, "call SafeImage.configure!(backend: :vips | :imagemagick, landlock: true | false) before using SafeImage")
end

.configure!(backend:, landlock:, max_pixels: DEFAULT_MAX_PIXELS) ⇒ Object

Decides, in one place, everything that varies by host: which backend decodes untrusted bytes, whether operations run inside the Landlock sandbox, and the default decompression-bomb ceiling. Must be called before any operation; calling it again replaces the configuration.

Validation is eager so a misconfigured host fails at boot rather than on the first request.

Raises:

  • (ArgumentError)


64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/safe_image.rb', line 64

def configure!(backend:, landlock:, max_pixels: DEFAULT_MAX_PIXELS)
  backend = backend.to_sym
  unless BACKENDS.include?(backend)
    raise ArgumentError, "unknown backend: #{backend.inspect} (expected :vips or :imagemagick)"
  end
  unless [true, false].include?(landlock)
    raise ArgumentError, "landlock must be true or false, got: #{landlock.inspect}"
  end
  max_pixels = Integer(max_pixels)
  raise ArgumentError, "max_pixels must be positive" if max_pixels <= 0

  case backend
  when :vips
    begin
      VipsGlue.init!
    rescue VipsUnavailableError => e
      raise Error, "backend: :vips requested but libvips is unavailable: #{e.message}"
    end
  when :imagemagick
    unless Runner.available?("magick") || Runner.available?("convert")
      raise Error, "backend: :imagemagick requested but no magick/convert executable was found"
    end
  end
  if landlock && !Sandbox.available?
    raise Error, "landlock: true requested but the Landlock sandbox is unavailable on this host"
  end

  # The zygote bakes the backend and max_pixels in at boot; a reconfigure
  # must not serve from a stale one.
  Zygote.shutdown!

  @config = Config.new(backend: backend, landlock: landlock, max_pixels: max_pixels)
end

.configured?Boolean

Returns:

  • (Boolean)


102
# File 'lib/safe_image.rb', line 102

def configured? = !@config.nil?

.convert(*args, **kwargs) ⇒ Object



346
347
348
# File 'lib/safe_image.rb', line 346

def convert(*args, **kwargs)
  maybe_sandbox(:convert, args: args, kwargs: kwargs) { DiscourseCompat.convert(*args, **kwargs) }
end

.convert_favicon_to_png(*args, **kwargs) ⇒ Object



358
359
360
# File 'lib/safe_image.rb', line 358

def convert_favicon_to_png(*args, **kwargs)
  maybe_sandbox(:convert_favicon_to_png, args: args, kwargs: kwargs) { DiscourseCompat.convert_favicon_to_png(*args, **kwargs) }
end

.convert_to_jpeg(*args, **kwargs) ⇒ Object



350
351
352
# File 'lib/safe_image.rb', line 350

def convert_to_jpeg(*args, **kwargs)
  maybe_sandbox(:convert_to_jpeg, args: args, kwargs: kwargs) { DiscourseCompat.convert_to_jpeg(*args, **kwargs) }
end

.crop(*args, **kwargs) ⇒ Object



338
339
340
# File 'lib/safe_image.rb', line 338

def crop(*args, **kwargs)
  maybe_sandbox(:crop, args: args, kwargs: kwargs) { DiscourseCompat.crop(*args, **kwargs) }
end

.dimensions(path, max_pixels: nil) ⇒ Object



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

def dimensions(path, max_pixels: nil)
  size(path, max_pixels: max_pixels)
end

.dominant_color(path, max_pixels: nil) ⇒ Object



237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/safe_image.rb', line 237

def dominant_color(path, max_pixels: nil)
  maybe_sandbox(:dominant_color, args: [path], kwargs: { max_pixels: max_pixels }) do
    max_pixels = resolved_max_pixels(max_pixels)
    case config.backend
    when :vips
      if File.extname(PathSafety.local_path(path)).downcase == ".ico"
        # Pure-Ruby ICO decode; vips only averages the decoded pixels.
        Ico.dominant_color(path, max_pixels: max_pixels)
      else
        VipsBackend.dominant_color(path, max_pixels: max_pixels)
      end
    when :imagemagick
      imagemagick_dominant_color(path, max_pixels: max_pixels)
    end
  end
end

.downsize(*args, **kwargs) ⇒ Object



342
343
344
# File 'lib/safe_image.rb', line 342

def downsize(*args, **kwargs)
  maybe_sandbox(:downsize, args: args, kwargs: kwargs) { DiscourseCompat.downsize(*args, **kwargs) }
end

.fastimage_type(format) ⇒ Object



261
262
263
# File 'lib/safe_image.rb', line 261

def fastimage_type(format)
  format.to_s == "jpg" ? :jpeg : format.to_s.to_sym
end

.fetch_remote(url, **kwargs, &block) ⇒ Object



294
295
296
297
# File 'lib/safe_image.rb', line 294

def fetch_remote(url, **kwargs, &block)
  config
  Remote.fetch(url, **kwargs, &block)
end

.fix_orientation(*args, **kwargs) ⇒ Object



354
355
356
# File 'lib/safe_image.rb', line 354

def fix_orientation(*args, **kwargs)
  maybe_sandbox(:fix_orientation, args: args, kwargs: kwargs) { DiscourseCompat.fix_orientation(*args, **kwargs) }
end

.frame_count(*args, **kwargs) ⇒ Object



362
363
364
# File 'lib/safe_image.rb', line 362

def frame_count(*args, **kwargs)
  maybe_sandbox(:frame_count, args: args, kwargs: kwargs) { DiscourseCompat.frame_count(*args, **kwargs) }
end

.imagemagick_dominant_color(path, max_pixels:) ⇒ Object



254
255
256
257
258
259
# File 'lib/safe_image.rb', line 254

def imagemagick_dominant_color(path, max_pixels:)
  # Probe first: rejects undecodable files and enforces the pixel cap
  # before ImageMagick fully decodes the image to average it.
  probe(path, max_pixels: max_pixels)
  ImageMagickBackend.dominant_color(path)
end

.info(path, max_pixels: nil, animated: false, orientation: false) ⇒ Object



200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/safe_image.rb', line 200

def info(path, max_pixels: nil, animated: false, orientation: false)
  maybe_sandbox(:info, args: [path], kwargs: { max_pixels: max_pixels, animated: animated, orientation: orientation }) do
    result = probe(path, max_pixels: max_pixels)
    type = fastimage_type(result.input_format)
    Info.new(
      path: result.input,
      type: type,
      width: result.width,
      height: result.height,
      size: [result.width, result.height],
      animated: animated ? animated?(path, max_pixels: max_pixels) : nil,
      orientation: orientation ? orientation(path, max_pixels: max_pixels) : nil
    )
  end
end

.letter_avatar(*args, **kwargs) ⇒ Object



374
375
376
# File 'lib/safe_image.rb', line 374

def letter_avatar(*args, **kwargs)
  maybe_sandbox(:letter_avatar, args: args, kwargs: kwargs) { DiscourseCompat.letter_avatar(*args, **kwargs) }
end

.maybe_sandbox(operation, args: [], kwargs: {}) ⇒ Object



118
119
120
121
122
123
# File 'lib/safe_image.rb', line 118

def maybe_sandbox(operation, args: [], kwargs: {})
  config
  return yield unless sandbox?

  Sandbox.public_call!(operation, args: args, kwargs: kwargs)
end

.optimize(path, mode: :lossless, strip_metadata: true, quality: nil, strict: true) ⇒ Object



328
329
330
331
332
# File 'lib/safe_image.rb', line 328

def optimize(path, mode: :lossless, strip_metadata: true, quality: nil, strict: true)
  maybe_sandbox(:optimize, args: [path], kwargs: { mode: mode, strip_metadata: , quality: quality, strict: strict }) do
    Optimizer.optimize(path, mode: mode, strip_metadata: , quality: quality, strict: strict)
  end
end

.optimize_image!(*args, **kwargs) ⇒ Object



378
379
380
# File 'lib/safe_image.rb', line 378

def optimize_image!(*args, **kwargs)
  maybe_sandbox(:optimize_image!, args: args, kwargs: kwargs) { DiscourseCompat.optimize_image!(*args, **kwargs) }
end

.orientation(path, max_pixels: nil) ⇒ Object



216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/safe_image.rb', line 216

def orientation(path, max_pixels: nil)
  maybe_sandbox(:orientation, args: [path], kwargs: { max_pixels: max_pixels }) do
    case File.extname(PathSafety.local_path(path)).downcase
    when ".svg", ".ico"
      # No EXIF orientation in either format; upright by definition.
      1
    else
      max_pixels = resolved_max_pixels(max_pixels)
      case config.backend
      when :vips
        # Header-only native read.
        VipsBackend.orientation(path, max_pixels: max_pixels)
      when :imagemagick
        # Probe first: rejects undecodable files and enforces the pixel cap.
        ImageMagickBackend.probe(path, max_pixels: max_pixels)
        ImageMagickBackend.orientation(path)
      end
    end
  end
end

.probe(path, max_pixels: nil) ⇒ Object



125
126
127
128
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
176
177
178
179
180
181
# File 'lib/safe_image.rb', line 125

def probe(path, max_pixels: nil)
  maybe_sandbox(:probe, args: [path], kwargs: { max_pixels: max_pixels }) do
    path = PathSafety.local_path(path)
    max_pixels = resolved_max_pixels(max_pixels)

    case File.extname(path).downcase
    when ".svg"
      info = SvgMetadata.probe(path, max_pixels: max_pixels)
      Result.new(
        input: File.expand_path(path),
        output: nil,
        input_format: "svg",
        output_format: nil,
        width: info.fetch(:width),
        height: info.fetch(:height),
        filesize: File.size(path),
        backend: "svg-metadata",
        duration_ms: info.fetch(:duration_ms),
        optimizer: nil
      )
    when ".ico"
      # Pure-Ruby directory parse; reports the largest entry's dimensions.
      info = Ico.probe(path, max_pixels: max_pixels)
      Result.new(
        input: File.expand_path(path),
        output: nil,
        input_format: "ico",
        output_format: nil,
        width: info.fetch(:width),
        height: info.fetch(:height),
        filesize: File.size(path),
        backend: "ico-metadata",
        duration_ms: info.fetch(:duration_ms),
        optimizer: nil
      )
    else
      case config.backend
      when :vips
        Processor.new(max_pixels: max_pixels).probe(path)
      when :imagemagick
        info = ImageMagickBackend.probe(path, max_pixels: max_pixels)
        Result.new(
          input: File.expand_path(path),
          output: nil,
          input_format: info.fetch(:input_format),
          output_format: nil,
          width: info.fetch(:width),
          height: info.fetch(:height),
          filesize: File.size(path),
          backend: "imagemagick",
          duration_ms: info.fetch(:duration_ms),
          optimizer: nil
        )
      end
    end
  end
end

.remote_animated?(url, **kwargs) ⇒ Boolean

Returns:

  • (Boolean)


284
285
286
287
# File 'lib/safe_image.rb', line 284

def remote_animated?(url, **kwargs)
  config
  Remote.animated?(url, **kwargs)
end

.remote_dimensions(url, **kwargs) ⇒ Object



275
276
277
# File 'lib/safe_image.rb', line 275

def remote_dimensions(url, **kwargs)
  remote_size(url, **kwargs)
end

.remote_dominant_color(url, **kwargs) ⇒ Object



289
290
291
292
# File 'lib/safe_image.rb', line 289

def remote_dominant_color(url, **kwargs)
  config
  Remote.dominant_color(url, **kwargs)
end

.remote_info(url, **kwargs) ⇒ Object



265
266
267
268
# File 'lib/safe_image.rb', line 265

def remote_info(url, **kwargs)
  config
  Remote.info(url, **kwargs)
end

.remote_size(url, **kwargs) ⇒ Object



270
271
272
273
# File 'lib/safe_image.rb', line 270

def remote_size(url, **kwargs)
  config
  Remote.size(url, **kwargs)
end

.remote_type(url, **kwargs) ⇒ Object



279
280
281
282
# File 'lib/safe_image.rb', line 279

def remote_type(url, **kwargs)
  config
  Remote.type(url, **kwargs)
end

.resize(*args, **kwargs) ⇒ Object



334
335
336
# File 'lib/safe_image.rb', line 334

def resize(*args, **kwargs)
  maybe_sandbox(:resize, args: args, kwargs: kwargs) { DiscourseCompat.resize(*args, **kwargs) }
end

.resolved_max_pixels(max_pixels) ⇒ Object

Internal: per-call max_pixels overrides the configured default.



114
115
116
# File 'lib/safe_image.rb', line 114

def resolved_max_pixels(max_pixels)
  max_pixels.nil? ? config.max_pixels : max_pixels
end

.sandbox?Boolean

Internal: whether operations must route through the sandbox worker. False before configure! (so configure!‘s own availability probes can run commands) and inside worker children (so sandboxed operations never nest).

Returns:

  • (Boolean)


109
110
111
# File 'lib/safe_image.rb', line 109

def sandbox?
  !!@config&.landlock && ENV["SAFE_IMAGE_SANDBOX_CHILD"] != "1"
end

.sandbox_available?Boolean

Returns:

  • (Boolean)


104
# File 'lib/safe_image.rb', line 104

def sandbox_available? = Sandbox.available?

.sanitize_svg!(*args, **kwargs) ⇒ Object



382
383
384
385
386
387
388
389
390
# File 'lib/safe_image.rb', line 382

def sanitize_svg!(*args, **kwargs)
  # Validate the required id_namespace in the parent (after the configured
  # check) so omitting/malformed values raise ArgumentError consistently —
  # otherwise, under the sandbox, the worker raises and it surfaces as a
  # sandbox CommandError instead of the documented ArgumentError.
  config
  SvgSanitizer.resolve_namespace(kwargs.fetch(:id_namespace, SvgSanitizer::NAMESPACE_REQUIRED))
  maybe_sandbox(:sanitize_svg!, args: args, kwargs: kwargs) { SvgSanitizer.sanitize!(*args, **kwargs) }
end

.size(path, max_pixels: nil) ⇒ Object



189
190
191
192
193
194
# File 'lib/safe_image.rb', line 189

def size(path, max_pixels: nil)
  maybe_sandbox(:size, args: [path], kwargs: { max_pixels: max_pixels }) do
    result = probe(path, max_pixels: max_pixels)
    [result.width, result.height]
  end
end

.thumbnail(input:, output:, width:, height:, format: nil, quality: 85, max_pixels: nil, optimize: false, optimize_mode: :lossless, chroma_subsampling: :auto) ⇒ Object



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
# File 'lib/safe_image.rb', line 299

def thumbnail(input:, output:, width:, height:, format: nil, quality: 85, max_pixels: nil, optimize: false, optimize_mode: :lossless, chroma_subsampling: :auto)
  maybe_sandbox(
    :thumbnail,
    kwargs: {
      input: input,
      output: output,
      width: width,
      height: height,
      format: format,
      quality: quality,
      max_pixels: max_pixels,
      optimize: optimize,
      optimize_mode: optimize_mode,
      chroma_subsampling: chroma_subsampling
    }
  ) do
    Processor.new(max_pixels: resolved_max_pixels(max_pixels), chroma_subsampling: chroma_subsampling).thumbnail(
      input: input,
      output: output,
      width: width,
      height: height,
      format: format,
      quality: quality,
      optimize: optimize,
      optimize_mode: optimize_mode
    )
  end
end

.type(path, max_pixels: nil) ⇒ Object



183
184
185
186
187
# File 'lib/safe_image.rb', line 183

def type(path, max_pixels: nil)
  maybe_sandbox(:type, args: [path], kwargs: { max_pixels: max_pixels }) do
    fastimage_type(probe(path, max_pixels: max_pixels).input_format)
  end
end