safe_image 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.
data/lib/safe_image.rb CHANGED
@@ -4,16 +4,31 @@ require_relative "safe_image/version"
4
4
 
5
5
  module SafeImage
6
6
  class Error < StandardError; end
7
+
8
+ # Raised when any operation is attempted before SafeImage.configure!.
9
+ class NotConfiguredError < Error; end
10
+
7
11
  class UnsupportedFormatError < Error; end
12
+
13
+ # Raised when libvips cannot be loaded at runtime. configure!(backend: :vips)
14
+ # surfaces this at boot; operations never fall back to ImageMagick.
15
+ class VipsUnavailableError < UnsupportedFormatError; end
8
16
  class UnsafePathError < Error; end
9
17
  class InvalidImageError < Error; end
10
18
  class LimitError < Error; end
11
19
 
12
- # Default decompression-bomb ceiling for the libvips processing path when the
13
- # caller does not pass an explicit max_pixels. Mirrored in the native
14
- # extension (SAFE_IMAGE_DEFAULT_MAX_PIXELS) and aligned with the 128MP area
15
- # limit on the ImageMagick path. Pass max_pixels to raise or lower it.
20
+ # Default decompression-bomb ceiling when configure! is not given an explicit
21
+ # max_pixels. Mirrored in the native binding (SAFE_IMAGE_DEFAULT_MAX_PIXELS)
22
+ # and aligned with the 128MP area limit on the ImageMagick path. Per-call
23
+ # max_pixels: overrides the configured value.
16
24
  DEFAULT_MAX_PIXELS = 128 * 1024 * 1024
25
+
26
+ BACKENDS = %i[vips imagemagick].freeze
27
+
28
+ # Process-wide configuration. configure! builds a frozen instance and swaps
29
+ # it in with a single assignment, so readers never observe a half-applied
30
+ # config.
31
+ Config = Data.define(:backend, :landlock, :max_pixels)
17
32
  end
18
33
 
19
34
  require_relative "safe_image/native"
@@ -25,6 +40,7 @@ require_relative "safe_image/optimizer"
25
40
  require_relative "safe_image/svg_metadata"
26
41
  require_relative "safe_image/svg_sanitizer"
27
42
  require_relative "safe_image/remote"
43
+ require_relative "safe_image/ico"
28
44
  require_relative "safe_image/image_magick_backend"
29
45
  require_relative "safe_image/jpegli_backend"
30
46
  require_relative "safe_image/vips_backend"
@@ -34,46 +50,79 @@ require_relative "safe_image/discourse_compat"
34
50
  module SafeImage
35
51
  module_function
36
52
 
37
- @sandbox_enabled = false
38
-
39
- def enable_sandbox!
40
- raise Error, "landlock sandbox requested but unavailable" unless Sandbox.available?
41
- @sandbox_enabled = true
42
- end
53
+ @config = nil
54
+
55
+ # Decides, in one place, everything that varies by host: which backend
56
+ # decodes untrusted bytes, whether operations run inside the Landlock
57
+ # sandbox, and the default decompression-bomb ceiling. Must be called before
58
+ # any operation; calling it again replaces the configuration.
59
+ #
60
+ # Validation is eager so a misconfigured host fails at boot rather than on
61
+ # the first request.
62
+ def configure!(backend:, landlock:, max_pixels: DEFAULT_MAX_PIXELS)
63
+ backend = backend.to_sym
64
+ unless BACKENDS.include?(backend)
65
+ raise ArgumentError, "unknown backend: #{backend.inspect} (expected :vips or :imagemagick)"
66
+ end
67
+ unless [true, false].include?(landlock)
68
+ raise ArgumentError, "landlock must be true or false, got: #{landlock.inspect}"
69
+ end
70
+ max_pixels = Integer(max_pixels)
71
+ raise ArgumentError, "max_pixels must be positive" if max_pixels <= 0
72
+
73
+ case backend
74
+ when :vips
75
+ begin
76
+ VipsGlue.init!
77
+ rescue VipsUnavailableError => e
78
+ raise Error, "backend: :vips requested but libvips is unavailable: #{e.message}"
79
+ end
80
+ when :imagemagick
81
+ unless Runner.available?("magick") || Runner.available?("convert")
82
+ raise Error, "backend: :imagemagick requested but no magick/convert executable was found"
83
+ end
84
+ end
85
+ if landlock && !Sandbox.available?
86
+ raise Error, "landlock: true requested but the Landlock sandbox is unavailable on this host"
87
+ end
43
88
 
44
- def disable_sandbox!
45
- @sandbox_enabled = false
89
+ @config = Config.new(backend: backend, landlock: landlock, max_pixels: max_pixels)
46
90
  end
47
91
 
48
- def sandbox_enabled?
49
- @sandbox_enabled && ENV["SAFE_IMAGE_SANDBOX_CHILD"] != "1"
92
+ def config
93
+ @config || raise(NotConfiguredError, "call SafeImage.configure!(backend: :vips | :imagemagick, landlock: true | false) before using SafeImage")
50
94
  end
51
95
 
52
- def with_sandbox_disabled
53
- previous = @sandbox_enabled
54
- @sandbox_enabled = false
55
- yield
56
- ensure
57
- @sandbox_enabled = previous
58
- end
96
+ def configured? = !@config.nil?
59
97
 
60
98
  def sandbox_available? = Sandbox.available?
61
99
 
62
- def sandbox_call(operation, args: [], kwargs: {})
63
- Sandbox.public_call!(operation, args: args, kwargs: kwargs)
100
+ # Internal: whether operations must route through the sandbox worker. False
101
+ # before configure! (so configure!'s own availability probes can run
102
+ # commands) and inside worker children (so sandboxed operations never nest).
103
+ def sandbox?
104
+ !!@config&.landlock && ENV["SAFE_IMAGE_SANDBOX_CHILD"] != "1"
105
+ end
106
+
107
+ # Internal: per-call max_pixels overrides the configured default.
108
+ def resolved_max_pixels(max_pixels)
109
+ max_pixels.nil? ? config.max_pixels : max_pixels
64
110
  end
65
111
 
66
112
  def maybe_sandbox(operation, args: [], kwargs: {})
67
- return yield unless sandbox_enabled?
113
+ config
114
+ return yield unless sandbox?
68
115
 
69
- sandbox_call(operation, args: args, kwargs: kwargs)
116
+ Sandbox.public_call!(operation, args: args, kwargs: kwargs)
70
117
  end
71
118
 
72
119
  def probe(path, max_pixels: nil)
73
120
  maybe_sandbox(:probe, args: [path], kwargs: { max_pixels: max_pixels }) do
74
121
  path = PathSafety.local_path(path)
122
+ max_pixels = resolved_max_pixels(max_pixels)
75
123
 
76
- if File.extname(path).downcase == ".svg"
124
+ case File.extname(path).downcase
125
+ when ".svg"
77
126
  info = SvgMetadata.probe(path, max_pixels: max_pixels)
78
127
  Result.new(
79
128
  input: File.expand_path(path),
@@ -87,10 +136,26 @@ module SafeImage
87
136
  duration_ms: info.fetch(:duration_ms),
88
137
  optimizer: nil
89
138
  )
139
+ when ".ico"
140
+ # Pure-Ruby directory parse; reports the largest entry's dimensions.
141
+ info = Ico.probe(path, max_pixels: max_pixels)
142
+ Result.new(
143
+ input: File.expand_path(path),
144
+ output: nil,
145
+ input_format: "ico",
146
+ output_format: nil,
147
+ width: info.fetch(:width),
148
+ height: info.fetch(:height),
149
+ filesize: File.size(path),
150
+ backend: "ico-metadata",
151
+ duration_ms: info.fetch(:duration_ms),
152
+ optimizer: nil
153
+ )
90
154
  else
91
- begin
155
+ case config.backend
156
+ when :vips
92
157
  Processor.new(max_pixels: max_pixels).probe(path)
93
- rescue UnsupportedFormatError
158
+ when :imagemagick
94
159
  info = ImageMagickBackend.probe(path, max_pixels: max_pixels)
95
160
  Result.new(
96
161
  input: File.expand_path(path),
@@ -144,24 +209,60 @@ module SafeImage
144
209
 
145
210
  def orientation(path, max_pixels: nil)
146
211
  maybe_sandbox(:orientation, args: [path], kwargs: { max_pixels: max_pixels }) do
147
- if File.extname(PathSafety.local_path(path)).downcase == ".svg"
212
+ case File.extname(PathSafety.local_path(path)).downcase
213
+ when ".svg", ".ico"
214
+ # No EXIF orientation in either format; upright by definition.
148
215
  1
149
216
  else
150
- probe(path, max_pixels: max_pixels) if max_pixels
151
- ImageMagickBackend.orientation(path)
217
+ max_pixels = resolved_max_pixels(max_pixels)
218
+ case config.backend
219
+ when :vips
220
+ # Header-only native read.
221
+ VipsBackend.orientation(path, max_pixels: max_pixels)
222
+ when :imagemagick
223
+ # Probe first: rejects undecodable files and enforces the pixel cap.
224
+ ImageMagickBackend.probe(path, max_pixels: max_pixels)
225
+ ImageMagickBackend.orientation(path)
226
+ end
152
227
  end
153
228
  end
154
229
  end
155
230
 
231
+ def dominant_color(path, max_pixels: nil)
232
+ maybe_sandbox(:dominant_color, args: [path], kwargs: { max_pixels: max_pixels }) do
233
+ max_pixels = resolved_max_pixels(max_pixels)
234
+ case config.backend
235
+ when :vips
236
+ if File.extname(PathSafety.local_path(path)).downcase == ".ico"
237
+ # Pure-Ruby ICO decode; vips only averages the decoded pixels.
238
+ Ico.dominant_color(path, max_pixels: max_pixels)
239
+ else
240
+ VipsBackend.dominant_color(path, max_pixels: max_pixels)
241
+ end
242
+ when :imagemagick
243
+ imagemagick_dominant_color(path, max_pixels: max_pixels)
244
+ end
245
+ end
246
+ end
247
+
248
+ def imagemagick_dominant_color(path, max_pixels:)
249
+ # Probe first: rejects undecodable files and enforces the pixel cap
250
+ # before ImageMagick fully decodes the image to average it.
251
+ probe(path, max_pixels: max_pixels)
252
+ ImageMagickBackend.dominant_color(path)
253
+ end
254
+
156
255
  def fastimage_type(format)
157
256
  format.to_s == "jpg" ? :jpeg : format.to_s.to_sym
158
257
  end
159
258
 
160
259
  def remote_info(url, **kwargs)
260
+ config
161
261
  Remote.info(url, **kwargs)
162
262
  end
163
263
 
164
264
  def remote_size(url, **kwargs)
265
+ config
165
266
  Remote.size(url, **kwargs)
166
267
  end
167
268
 
@@ -170,18 +271,26 @@ module SafeImage
170
271
  end
171
272
 
172
273
  def remote_type(url, **kwargs)
274
+ config
173
275
  Remote.type(url, **kwargs)
174
276
  end
175
277
 
176
278
  def remote_animated?(url, **kwargs)
279
+ config
177
280
  Remote.animated?(url, **kwargs)
178
281
  end
179
282
 
283
+ def remote_dominant_color(url, **kwargs)
284
+ config
285
+ Remote.dominant_color(url, **kwargs)
286
+ end
287
+
180
288
  def fetch_remote(url, **kwargs, &block)
289
+ config
181
290
  Remote.fetch(url, **kwargs, &block)
182
291
  end
183
292
 
184
- def thumbnail(input:, output:, width:, height:, format: nil, quality: 85, max_pixels: nil, backend: :vips, optimize: false, optimize_mode: :lossless, execution: :inline, encoder: :auto, chroma_subsampling: :auto)
293
+ def thumbnail(input:, output:, width:, height:, format: nil, quality: 85, max_pixels: nil, optimize: false, optimize_mode: :lossless, chroma_subsampling: :auto)
185
294
  maybe_sandbox(
186
295
  :thumbnail,
187
296
  kwargs: {
@@ -192,15 +301,12 @@ module SafeImage
192
301
  format: format,
193
302
  quality: quality,
194
303
  max_pixels: max_pixels,
195
- backend: backend,
196
304
  optimize: optimize,
197
305
  optimize_mode: optimize_mode,
198
- execution: :inline,
199
- encoder: encoder,
200
306
  chroma_subsampling: chroma_subsampling
201
307
  }
202
308
  ) do
203
- Processor.new(max_pixels: max_pixels, backend: backend, execution: execution, encoder: encoder, chroma_subsampling: chroma_subsampling).thumbnail(
309
+ Processor.new(max_pixels: resolved_max_pixels(max_pixels), chroma_subsampling: chroma_subsampling).thumbnail(
204
310
  input: input,
205
311
  output: output,
206
312
  width: width,
@@ -252,6 +358,7 @@ module SafeImage
252
358
  end
253
359
 
254
360
  def animated?(*args, **kwargs)
361
+ config
255
362
  path = args.first
256
363
  return false if path && File.extname(PathSafety.local_path(path)).downcase == ".svg"
257
364
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: safe_image
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Saffron
@@ -10,6 +10,20 @@ bindir: bin
10
10
  cert_chain: []
11
11
  date: 1980-01-02 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: fiddle
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: rexml
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -53,51 +67,52 @@ dependencies:
53
67
  - !ruby/object:Gem::Version
54
68
  version: '13.0'
55
69
  - !ruby/object:Gem::Dependency
56
- name: rake-compiler
70
+ name: rubocop-discourse
57
71
  requirement: !ruby/object:Gem::Requirement
58
72
  requirements:
59
73
  - - "~>"
60
74
  - !ruby/object:Gem::Version
61
- version: '1.2'
75
+ version: '3.18'
62
76
  type: :development
63
77
  prerelease: false
64
78
  version_requirements: !ruby/object:Gem::Requirement
65
79
  requirements:
66
80
  - - "~>"
67
81
  - !ruby/object:Gem::Version
68
- version: '1.2'
82
+ version: '3.18'
69
83
  - !ruby/object:Gem::Dependency
70
- name: rubocop-discourse
84
+ name: landlock
71
85
  requirement: !ruby/object:Gem::Requirement
72
86
  requirements:
73
- - - "~>"
87
+ - - ">="
74
88
  - !ruby/object:Gem::Version
75
- version: '3.18'
89
+ version: '0'
76
90
  type: :development
77
91
  prerelease: false
78
92
  version_requirements: !ruby/object:Gem::Requirement
79
93
  requirements:
80
- - - "~>"
94
+ - - ">="
81
95
  - !ruby/object:Gem::Version
82
- version: '3.18'
96
+ version: '0'
83
97
  description: 'Safe Image is a small Ruby image-processing boundary for untrusted uploads:
84
98
  direct libvips thumbnails/probing, hardened ImageMagick compatibility operations,
85
99
  optimisation, SVG sanitising, and optional atomic Landlock sandbox execution.'
86
100
  email:
87
101
  - sam@discourse.org
88
102
  executables: []
89
- extensions:
90
- - ext/safe_image_native/extconf.rb
103
+ extensions: []
91
104
  extra_rdoc_files: []
92
105
  files:
106
+ - CHANGELOG.md
93
107
  - LICENSE
94
108
  - README.md
95
109
  - SECURITY.md
96
- - ext/safe_image_native/extconf.rb
97
- - ext/safe_image_native/safe_image_native.c
98
110
  - lib/safe_image.rb
99
111
  - lib/safe_image/RT_sRGB.icm
100
112
  - lib/safe_image/discourse_compat.rb
113
+ - lib/safe_image/fonts/DEJAVU-LICENSE
114
+ - lib/safe_image/fonts/DejaVuSans.ttf
115
+ - lib/safe_image/ico.rb
101
116
  - lib/safe_image/image_magick_backend.rb
102
117
  - lib/safe_image/imagemagick_policy/policy.xml
103
118
  - lib/safe_image/jpegli_backend.rb
@@ -113,6 +128,7 @@ files:
113
128
  - lib/safe_image/svg_sanitizer.rb
114
129
  - lib/safe_image/version.rb
115
130
  - lib/safe_image/vips_backend.rb
131
+ - lib/safe_image/vips_glue.rb
116
132
  homepage: https://github.com/sam-saffron-jarvis/safe-image
117
133
  licenses:
118
134
  - MIT
@@ -134,7 +150,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
134
150
  - !ruby/object:Gem::Version
135
151
  version: '0'
136
152
  requirements: []
137
- rubygems_version: 4.0.6
153
+ rubygems_version: 3.6.9
138
154
  specification_version: 4
139
155
  summary: Hardened image processing boundary for untrusted uploads
140
156
  test_files: []
@@ -1,8 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "mkmf"
4
-
5
- pkg_config("vips") or abort "libvips development files are required (pkg-config vips failed)"
6
- have_header("vips/vips.h") or abort "missing vips/vips.h"
7
- have_library("vips") or abort "missing libvips"
8
- create_makefile("safe_image_native")