safe_image 0.1.0 → 0.3.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,27 +4,45 @@ 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"
20
35
  require_relative "safe_image/result"
21
36
  require_relative "safe_image/runner"
22
37
  require_relative "safe_image/sandbox"
38
+ require_relative "safe_image/zygote"
23
39
  require_relative "safe_image/path_safety"
24
40
  require_relative "safe_image/optimizer"
25
41
  require_relative "safe_image/svg_metadata"
42
+ require_relative "safe_image/svg_css"
26
43
  require_relative "safe_image/svg_sanitizer"
27
44
  require_relative "safe_image/remote"
45
+ require_relative "safe_image/ico"
28
46
  require_relative "safe_image/image_magick_backend"
29
47
  require_relative "safe_image/jpegli_backend"
30
48
  require_relative "safe_image/vips_backend"
@@ -34,46 +52,83 @@ require_relative "safe_image/discourse_compat"
34
52
  module SafeImage
35
53
  module_function
36
54
 
37
- @sandbox_enabled = false
55
+ @config = nil
56
+
57
+ # Decides, in one place, everything that varies by host: which backend
58
+ # decodes untrusted bytes, whether operations run inside the Landlock
59
+ # sandbox, and the default decompression-bomb ceiling. Must be called before
60
+ # any operation; calling it again replaces the configuration.
61
+ #
62
+ # Validation is eager so a misconfigured host fails at boot rather than on
63
+ # the first request.
64
+ def configure!(backend:, landlock:, max_pixels: DEFAULT_MAX_PIXELS)
65
+ backend = backend.to_sym
66
+ unless BACKENDS.include?(backend)
67
+ raise ArgumentError, "unknown backend: #{backend.inspect} (expected :vips or :imagemagick)"
68
+ end
69
+ unless [true, false].include?(landlock)
70
+ raise ArgumentError, "landlock must be true or false, got: #{landlock.inspect}"
71
+ end
72
+ max_pixels = Integer(max_pixels)
73
+ raise ArgumentError, "max_pixels must be positive" if max_pixels <= 0
74
+
75
+ case backend
76
+ when :vips
77
+ begin
78
+ VipsGlue.init!
79
+ rescue VipsUnavailableError => e
80
+ raise Error, "backend: :vips requested but libvips is unavailable: #{e.message}"
81
+ end
82
+ when :imagemagick
83
+ unless Runner.available?("magick") || Runner.available?("convert")
84
+ raise Error, "backend: :imagemagick requested but no magick/convert executable was found"
85
+ end
86
+ end
87
+ if landlock && !Sandbox.available?
88
+ raise Error, "landlock: true requested but the Landlock sandbox is unavailable on this host"
89
+ end
38
90
 
39
- def enable_sandbox!
40
- raise Error, "landlock sandbox requested but unavailable" unless Sandbox.available?
41
- @sandbox_enabled = true
42
- end
91
+ # The zygote bakes the backend and max_pixels in at boot; a reconfigure
92
+ # must not serve from a stale one.
93
+ Zygote.shutdown!
43
94
 
44
- def disable_sandbox!
45
- @sandbox_enabled = false
95
+ @config = Config.new(backend: backend, landlock: landlock, max_pixels: max_pixels)
46
96
  end
47
97
 
48
- def sandbox_enabled?
49
- @sandbox_enabled && ENV["SAFE_IMAGE_SANDBOX_CHILD"] != "1"
98
+ def config
99
+ @config || raise(NotConfiguredError, "call SafeImage.configure!(backend: :vips | :imagemagick, landlock: true | false) before using SafeImage")
50
100
  end
51
101
 
52
- def with_sandbox_disabled
53
- previous = @sandbox_enabled
54
- @sandbox_enabled = false
55
- yield
56
- ensure
57
- @sandbox_enabled = previous
58
- end
102
+ def configured? = !@config.nil?
59
103
 
60
104
  def sandbox_available? = Sandbox.available?
61
105
 
62
- def sandbox_call(operation, args: [], kwargs: {})
63
- Sandbox.public_call!(operation, args: args, kwargs: kwargs)
106
+ # Internal: whether operations must route through the sandbox worker. False
107
+ # before configure! (so configure!'s own availability probes can run
108
+ # commands) and inside worker children (so sandboxed operations never nest).
109
+ def sandbox?
110
+ !!@config&.landlock && ENV["SAFE_IMAGE_SANDBOX_CHILD"] != "1"
111
+ end
112
+
113
+ # Internal: per-call max_pixels overrides the configured default.
114
+ def resolved_max_pixels(max_pixels)
115
+ max_pixels.nil? ? config.max_pixels : max_pixels
64
116
  end
65
117
 
66
118
  def maybe_sandbox(operation, args: [], kwargs: {})
67
- return yield unless sandbox_enabled?
119
+ config
120
+ return yield unless sandbox?
68
121
 
69
- sandbox_call(operation, args: args, kwargs: kwargs)
122
+ Sandbox.public_call!(operation, args: args, kwargs: kwargs)
70
123
  end
71
124
 
72
125
  def probe(path, max_pixels: nil)
73
126
  maybe_sandbox(:probe, args: [path], kwargs: { max_pixels: max_pixels }) do
74
127
  path = PathSafety.local_path(path)
128
+ max_pixels = resolved_max_pixels(max_pixels)
75
129
 
76
- if File.extname(path).downcase == ".svg"
130
+ case File.extname(path).downcase
131
+ when ".svg"
77
132
  info = SvgMetadata.probe(path, max_pixels: max_pixels)
78
133
  Result.new(
79
134
  input: File.expand_path(path),
@@ -87,10 +142,26 @@ module SafeImage
87
142
  duration_ms: info.fetch(:duration_ms),
88
143
  optimizer: nil
89
144
  )
145
+ when ".ico"
146
+ # Pure-Ruby directory parse; reports the largest entry's dimensions.
147
+ info = Ico.probe(path, max_pixels: max_pixels)
148
+ Result.new(
149
+ input: File.expand_path(path),
150
+ output: nil,
151
+ input_format: "ico",
152
+ output_format: nil,
153
+ width: info.fetch(:width),
154
+ height: info.fetch(:height),
155
+ filesize: File.size(path),
156
+ backend: "ico-metadata",
157
+ duration_ms: info.fetch(:duration_ms),
158
+ optimizer: nil
159
+ )
90
160
  else
91
- begin
161
+ case config.backend
162
+ when :vips
92
163
  Processor.new(max_pixels: max_pixels).probe(path)
93
- rescue UnsupportedFormatError
164
+ when :imagemagick
94
165
  info = ImageMagickBackend.probe(path, max_pixels: max_pixels)
95
166
  Result.new(
96
167
  input: File.expand_path(path),
@@ -144,24 +215,60 @@ module SafeImage
144
215
 
145
216
  def orientation(path, max_pixels: nil)
146
217
  maybe_sandbox(:orientation, args: [path], kwargs: { max_pixels: max_pixels }) do
147
- if File.extname(PathSafety.local_path(path)).downcase == ".svg"
218
+ case File.extname(PathSafety.local_path(path)).downcase
219
+ when ".svg", ".ico"
220
+ # No EXIF orientation in either format; upright by definition.
148
221
  1
149
222
  else
150
- probe(path, max_pixels: max_pixels) if max_pixels
151
- ImageMagickBackend.orientation(path)
223
+ max_pixels = resolved_max_pixels(max_pixels)
224
+ case config.backend
225
+ when :vips
226
+ # Header-only native read.
227
+ VipsBackend.orientation(path, max_pixels: max_pixels)
228
+ when :imagemagick
229
+ # Probe first: rejects undecodable files and enforces the pixel cap.
230
+ ImageMagickBackend.probe(path, max_pixels: max_pixels)
231
+ ImageMagickBackend.orientation(path)
232
+ end
152
233
  end
153
234
  end
154
235
  end
155
236
 
237
+ def dominant_color(path, max_pixels: nil)
238
+ maybe_sandbox(:dominant_color, args: [path], kwargs: { max_pixels: max_pixels }) do
239
+ max_pixels = resolved_max_pixels(max_pixels)
240
+ case config.backend
241
+ when :vips
242
+ if File.extname(PathSafety.local_path(path)).downcase == ".ico"
243
+ # Pure-Ruby ICO decode; vips only averages the decoded pixels.
244
+ Ico.dominant_color(path, max_pixels: max_pixels)
245
+ else
246
+ VipsBackend.dominant_color(path, max_pixels: max_pixels)
247
+ end
248
+ when :imagemagick
249
+ imagemagick_dominant_color(path, max_pixels: max_pixels)
250
+ end
251
+ end
252
+ end
253
+
254
+ def imagemagick_dominant_color(path, max_pixels:)
255
+ # Probe first: rejects undecodable files and enforces the pixel cap
256
+ # before ImageMagick fully decodes the image to average it.
257
+ probe(path, max_pixels: max_pixels)
258
+ ImageMagickBackend.dominant_color(path)
259
+ end
260
+
156
261
  def fastimage_type(format)
157
262
  format.to_s == "jpg" ? :jpeg : format.to_s.to_sym
158
263
  end
159
264
 
160
265
  def remote_info(url, **kwargs)
266
+ config
161
267
  Remote.info(url, **kwargs)
162
268
  end
163
269
 
164
270
  def remote_size(url, **kwargs)
271
+ config
165
272
  Remote.size(url, **kwargs)
166
273
  end
167
274
 
@@ -170,18 +277,26 @@ module SafeImage
170
277
  end
171
278
 
172
279
  def remote_type(url, **kwargs)
280
+ config
173
281
  Remote.type(url, **kwargs)
174
282
  end
175
283
 
176
284
  def remote_animated?(url, **kwargs)
285
+ config
177
286
  Remote.animated?(url, **kwargs)
178
287
  end
179
288
 
289
+ def remote_dominant_color(url, **kwargs)
290
+ config
291
+ Remote.dominant_color(url, **kwargs)
292
+ end
293
+
180
294
  def fetch_remote(url, **kwargs, &block)
295
+ config
181
296
  Remote.fetch(url, **kwargs, &block)
182
297
  end
183
298
 
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)
299
+ def thumbnail(input:, output:, width:, height:, format: nil, quality: 85, max_pixels: nil, optimize: false, optimize_mode: :lossless, chroma_subsampling: :auto)
185
300
  maybe_sandbox(
186
301
  :thumbnail,
187
302
  kwargs: {
@@ -192,15 +307,12 @@ module SafeImage
192
307
  format: format,
193
308
  quality: quality,
194
309
  max_pixels: max_pixels,
195
- backend: backend,
196
310
  optimize: optimize,
197
311
  optimize_mode: optimize_mode,
198
- execution: :inline,
199
- encoder: encoder,
200
312
  chroma_subsampling: chroma_subsampling
201
313
  }
202
314
  ) do
203
- Processor.new(max_pixels: max_pixels, backend: backend, execution: execution, encoder: encoder, chroma_subsampling: chroma_subsampling).thumbnail(
315
+ Processor.new(max_pixels: resolved_max_pixels(max_pixels), chroma_subsampling: chroma_subsampling).thumbnail(
204
316
  input: input,
205
317
  output: output,
206
318
  width: width,
@@ -252,6 +364,7 @@ module SafeImage
252
364
  end
253
365
 
254
366
  def animated?(*args, **kwargs)
367
+ config
255
368
  path = args.first
256
369
  return false if path && File.extname(PathSafety.local_path(path)).downcase == ".svg"
257
370
 
@@ -267,6 +380,12 @@ module SafeImage
267
380
  end
268
381
 
269
382
  def sanitize_svg!(*args, **kwargs)
383
+ # Validate the required id_namespace in the parent (after the configured
384
+ # check) so omitting/malformed values raise ArgumentError consistently —
385
+ # otherwise, under the sandbox, the worker raises and it surfaces as a
386
+ # sandbox CommandError instead of the documented ArgumentError.
387
+ config
388
+ SvgSanitizer.resolve_namespace(kwargs.fetch(:id_namespace, SvgSanitizer::NAMESPACE_REQUIRED))
270
389
  maybe_sandbox(:sanitize_svg!, args: args, kwargs: kwargs) { SvgSanitizer.sanitize!(*args, **kwargs) }
271
390
  end
272
391
  end
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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Saffron
@@ -10,6 +10,34 @@ 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'
27
+ - !ruby/object:Gem::Dependency
28
+ name: nokogiri
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.16'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.16'
13
41
  - !ruby/object:Gem::Dependency
14
42
  name: rexml
15
43
  requirement: !ruby/object:Gem::Requirement
@@ -17,7 +45,7 @@ dependencies:
17
45
  - - "~>"
18
46
  - !ruby/object:Gem::Version
19
47
  version: '3.4'
20
- type: :runtime
48
+ type: :development
21
49
  prerelease: false
22
50
  version_requirements: !ruby/object:Gem::Requirement
23
51
  requirements:
@@ -53,51 +81,52 @@ dependencies:
53
81
  - !ruby/object:Gem::Version
54
82
  version: '13.0'
55
83
  - !ruby/object:Gem::Dependency
56
- name: rake-compiler
84
+ name: rubocop-discourse
57
85
  requirement: !ruby/object:Gem::Requirement
58
86
  requirements:
59
87
  - - "~>"
60
88
  - !ruby/object:Gem::Version
61
- version: '1.2'
89
+ version: '3.18'
62
90
  type: :development
63
91
  prerelease: false
64
92
  version_requirements: !ruby/object:Gem::Requirement
65
93
  requirements:
66
94
  - - "~>"
67
95
  - !ruby/object:Gem::Version
68
- version: '1.2'
96
+ version: '3.18'
69
97
  - !ruby/object:Gem::Dependency
70
- name: rubocop-discourse
98
+ name: landlock
71
99
  requirement: !ruby/object:Gem::Requirement
72
100
  requirements:
73
- - - "~>"
101
+ - - ">="
74
102
  - !ruby/object:Gem::Version
75
- version: '3.18'
103
+ version: '0'
76
104
  type: :development
77
105
  prerelease: false
78
106
  version_requirements: !ruby/object:Gem::Requirement
79
107
  requirements:
80
- - - "~>"
108
+ - - ">="
81
109
  - !ruby/object:Gem::Version
82
- version: '3.18'
110
+ version: '0'
83
111
  description: 'Safe Image is a small Ruby image-processing boundary for untrusted uploads:
84
112
  direct libvips thumbnails/probing, hardened ImageMagick compatibility operations,
85
113
  optimisation, SVG sanitising, and optional atomic Landlock sandbox execution.'
86
114
  email:
87
115
  - sam@discourse.org
88
116
  executables: []
89
- extensions:
90
- - ext/safe_image_native/extconf.rb
117
+ extensions: []
91
118
  extra_rdoc_files: []
92
119
  files:
120
+ - CHANGELOG.md
93
121
  - LICENSE
94
122
  - README.md
95
123
  - SECURITY.md
96
- - ext/safe_image_native/extconf.rb
97
- - ext/safe_image_native/safe_image_native.c
98
124
  - lib/safe_image.rb
99
125
  - lib/safe_image/RT_sRGB.icm
100
126
  - lib/safe_image/discourse_compat.rb
127
+ - lib/safe_image/fonts/DEJAVU-LICENSE
128
+ - lib/safe_image/fonts/DejaVuSans.ttf
129
+ - lib/safe_image/ico.rb
101
130
  - lib/safe_image/image_magick_backend.rb
102
131
  - lib/safe_image/imagemagick_policy/policy.xml
103
132
  - lib/safe_image/jpegli_backend.rb
@@ -109,10 +138,13 @@ files:
109
138
  - lib/safe_image/result.rb
110
139
  - lib/safe_image/runner.rb
111
140
  - lib/safe_image/sandbox.rb
141
+ - lib/safe_image/svg_css.rb
112
142
  - lib/safe_image/svg_metadata.rb
113
143
  - lib/safe_image/svg_sanitizer.rb
114
144
  - lib/safe_image/version.rb
115
145
  - lib/safe_image/vips_backend.rb
146
+ - lib/safe_image/vips_glue.rb
147
+ - lib/safe_image/zygote.rb
116
148
  homepage: https://github.com/sam-saffron-jarvis/safe-image
117
149
  licenses:
118
150
  - MIT
@@ -134,7 +166,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
134
166
  - !ruby/object:Gem::Version
135
167
  version: '0'
136
168
  requirements: []
137
- rubygems_version: 4.0.6
169
+ rubygems_version: 3.6.9
138
170
  specification_version: 4
139
171
  summary: Hardened image processing boundary for untrusted uploads
140
172
  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")