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/sandbox.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, SvgMetadata, SvgSanitizer, VipsBackend, VipsGlue Classes: CommandError, Config, Error, Info, InvalidImageError, LimitError, NotConfiguredError, Processor, Result, UnsafePathError, UnsupportedFormatError, VipsUnavailableError

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.2.0"

Class Method Summary collapse

Class Method Details

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

Returns:

  • (Boolean)


360
361
362
363
364
365
366
# File 'lib/safe_image.rb', line 360

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



92
93
94
# File 'lib/safe_image.rb', line 92

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)


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

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

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

.configured?Boolean

Returns:

  • (Boolean)


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

def configured? = !@config.nil?

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



340
341
342
# File 'lib/safe_image.rb', line 340

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

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



352
353
354
# File 'lib/safe_image.rb', line 352

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



344
345
346
# File 'lib/safe_image.rb', line 344

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



332
333
334
# File 'lib/safe_image.rb', line 332

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

.dimensions(path, max_pixels: nil) ⇒ Object



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

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

.dominant_color(path, max_pixels: nil) ⇒ Object



231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/safe_image.rb', line 231

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



336
337
338
# File 'lib/safe_image.rb', line 336

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

.fastimage_type(format) ⇒ Object



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

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

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



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

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

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



348
349
350
# File 'lib/safe_image.rb', line 348

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

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



356
357
358
# File 'lib/safe_image.rb', line 356

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



248
249
250
251
252
253
# File 'lib/safe_image.rb', line 248

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



194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'lib/safe_image.rb', line 194

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



368
369
370
# File 'lib/safe_image.rb', line 368

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



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

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



322
323
324
325
326
# File 'lib/safe_image.rb', line 322

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



372
373
374
# File 'lib/safe_image.rb', line 372

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



210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/safe_image.rb', line 210

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



119
120
121
122
123
124
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
# File 'lib/safe_image.rb', line 119

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)


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

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

.remote_dimensions(url, **kwargs) ⇒ Object



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

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

.remote_dominant_color(url, **kwargs) ⇒ Object



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

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

.remote_info(url, **kwargs) ⇒ Object



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

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

.remote_size(url, **kwargs) ⇒ Object



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

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

.remote_type(url, **kwargs) ⇒ Object



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

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

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



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

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.



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

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)


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

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

.sandbox_available?Boolean

Returns:

  • (Boolean)


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

def sandbox_available? = Sandbox.available?

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



376
377
378
# File 'lib/safe_image.rb', line 376

def sanitize_svg!(*args, **kwargs)
  maybe_sandbox(:sanitize_svg!, args: args, kwargs: kwargs) { SvgSanitizer.sanitize!(*args, **kwargs) }
end

.size(path, max_pixels: nil) ⇒ Object



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

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



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

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



177
178
179
180
181
# File 'lib/safe_image.rb', line 177

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