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.
@@ -16,7 +16,7 @@ module SafeImage
16
16
  }.freeze
17
17
 
18
18
  OPERATIONS = %w[
19
- probe thumbnail type size dimensions info orientation optimize resize crop downsize convert convert_to_jpeg fix_orientation
19
+ probe thumbnail type size dimensions info orientation dominant_color optimize resize crop downsize convert convert_to_jpeg fix_orientation
20
20
  convert_favicon_to_png frame_count animated? letter_avatar optimize_image!
21
21
  sanitize_svg!
22
22
  ].freeze
@@ -50,7 +50,7 @@ module SafeImage
50
50
  raise Error, "landlock sandbox requested but the landlock gem is unavailable"
51
51
  rescue Landlock::SafeExec::CommandError => e
52
52
  raise CommandError.new(
53
- "sandboxed command failed",
53
+ "sandboxed command failed: #{failure_detail(e)}",
54
54
  command: argv,
55
55
  status: e.status&.exitstatus,
56
56
  stdout: e.stdout,
@@ -65,20 +65,21 @@ module SafeImage
65
65
  operation == "type" && result ? result.to_sym : result
66
66
  end
67
67
 
68
- def thumbnail(request)
69
- public_call!(
70
- :thumbnail,
71
- args: [],
72
- kwargs: request.merge(execution: :inline)
73
- )
74
- end
75
-
76
68
  def run_worker!(operation, request)
77
69
  operation = operation.to_s
78
70
  raise ArgumentError, "unsupported sandbox operation: #{operation}" unless OPERATIONS.include?(operation)
79
71
 
80
72
  require "landlock"
81
- payload = JSON.dump({ operation: operation, request: request })
73
+ config = SafeImage.config
74
+ payload = JSON.dump(
75
+ {
76
+ operation: operation,
77
+ request: request,
78
+ # The worker is a fresh process and must be configured like the
79
+ # parent — minus landlock, since it already runs inside the sandbox.
80
+ config: { backend: config.backend, max_pixels: config.max_pixels }
81
+ }
82
+ )
82
83
  code = <<~'RUBY'
83
84
  require "json"
84
85
  require "safe_image"
@@ -97,7 +98,7 @@ module SafeImage
97
98
  payload = JSON.parse(ARGV.fetch(0), symbolize_names: true)
98
99
  operation = payload.fetch(:operation).to_s
99
100
  allowed_operations = %w[
100
- probe thumbnail type size dimensions info orientation optimize resize crop downsize convert convert_to_jpeg fix_orientation
101
+ probe thumbnail type size dimensions info orientation dominant_color optimize resize crop downsize convert convert_to_jpeg fix_orientation
101
102
  convert_favicon_to_png frame_count animated? letter_avatar optimize_image! sanitize_svg!
102
103
  ]
103
104
  raise ArgumentError, "unsupported sandbox operation: #{operation}" unless allowed_operations.include?(operation)
@@ -106,9 +107,14 @@ module SafeImage
106
107
  args = request[:args] || []
107
108
  kwargs = deep_symbolize(request[:kwargs] || {})
108
109
 
109
- result = SafeImage.with_sandbox_disabled do
110
- SafeImage.__send__(operation, *args, **kwargs)
111
- end
110
+ config = payload.fetch(:config)
111
+ SafeImage.configure!(
112
+ backend: config.fetch(:backend).to_sym,
113
+ landlock: false,
114
+ max_pixels: config.fetch(:max_pixels)
115
+ )
116
+
117
+ result = SafeImage.__send__(operation, *args, **kwargs)
112
118
 
113
119
  if defined?(SafeImage::Result) && result.is_a?(SafeImage::Result)
114
120
  puts JSON.dump({ __type: "Result", data: result.to_h })
@@ -161,7 +167,7 @@ module SafeImage
161
167
  raise Error, "landlock sandbox requested but the landlock gem is unavailable"
162
168
  rescue Landlock::SafeExec::CommandError => e
163
169
  raise CommandError.new(
164
- "sandboxed worker failed",
170
+ "sandboxed worker failed: #{failure_detail(e)}",
165
171
  command: [RbConfig.ruby, "-e", "..."],
166
172
  status: e.status&.exitstatus,
167
173
  stdout: e.stdout,
@@ -221,7 +227,18 @@ module SafeImage
221
227
  paths << RbConfig::CONFIG["rubyarchdir"]
222
228
  paths << RbConfig::CONFIG["sitearchdir"]
223
229
  paths << RbConfig::CONFIG["vendorarchdir"]
230
+ # An --enable-shared Ruby installed outside the default read roots
231
+ # (e.g. GitHub Actions' /opt/hostedtoolcache builds) keeps libruby in
232
+ # libdir; without read access the worker dies at dynamic-link time
233
+ # before any Ruby code runs.
234
+ paths << RbConfig::CONFIG["libdir"]
224
235
  paths << File.dirname(RbConfig.ruby)
236
+ # Pango/fontconfig need the font directories and configs for the native
237
+ # letter_avatar text rendering inside the worker.
238
+ paths << "/etc/fonts"
239
+ paths << "/usr/share/fonts"
240
+ paths << "/usr/local/share/fonts"
241
+ paths << "/var/cache/fontconfig"
225
242
  paths
226
243
  end
227
244
 
@@ -229,6 +246,15 @@ module SafeImage
229
246
  paths.flatten.compact.map(&:to_s).reject(&:empty?).select { |path| File.exist?(path) }.uniq
230
247
  end
231
248
 
249
+ # Sandbox failures often happen before the child can run any Ruby (e.g. a
250
+ # denied shared-library read kills it at dynamic-link time); without the
251
+ # child's stderr in the message they are undiagnosable from a CI log.
252
+ def failure_detail(error)
253
+ detail = error.stderr.to_s.strip
254
+ detail = "exit status #{error.status&.exitstatus.inspect}" if detail.empty?
255
+ detail[0, 2000]
256
+ end
257
+
232
258
  def symbolize(hash)
233
259
  hash.transform_keys(&:to_sym)
234
260
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "pathname"
4
4
  require "rexml/document"
5
+ require "rexml/parsers/pullparser"
5
6
 
6
7
  module SafeImage
7
8
  module SvgMetadata
@@ -31,14 +32,13 @@ module SafeImage
31
32
  end
32
33
 
33
34
  def dimensions(path, max_pixels: nil, max_bytes: MAX_SVG_BYTES)
34
- path = safe_svg_path(path)
35
- doc = parse(path, max_bytes: max_bytes)
36
- root = doc.root
37
- width = parse_length(root.attributes["width"])
38
- height = parse_length(root.attributes["height"])
35
+ xml = read_svg(path, max_bytes: max_bytes)
36
+ _name, attributes = scan_svg!(xml)
37
+ width = parse_length(attributes["width"])
38
+ height = parse_length(attributes["height"])
39
39
 
40
40
  unless width && height
41
- view_box = parse_view_box(root.attributes["viewBox"])
41
+ view_box = parse_view_box(attributes["viewBox"])
42
42
  width ||= view_box&.fetch(2)
43
43
  height ||= view_box&.fetch(3)
44
44
  end
@@ -46,7 +46,22 @@ module SafeImage
46
46
  validate_dimensions!(width, height, max_pixels: max_pixels)
47
47
  end
48
48
 
49
+ # Builds the full REXML tree. Used only by the SVG sanitizer, which needs to
50
+ # walk and rewrite the document; metadata reads go through the DOM-free
51
+ # streaming path above. The streaming validation runs first so a document
52
+ # that breaches the structural caps is rejected before the tree is built.
49
53
  def parse(path, max_bytes: MAX_SVG_BYTES)
54
+ xml = read_svg(path, max_bytes: max_bytes)
55
+ scan_svg!(xml)
56
+ doc = REXML::Document.new(xml)
57
+ raise InvalidImageError, "SVG root required" unless doc.root&.name == "svg"
58
+
59
+ doc
60
+ rescue REXML::ParseException => e
61
+ raise InvalidImageError, "invalid SVG: #{e.message}"
62
+ end
63
+
64
+ def read_svg(path, max_bytes: MAX_SVG_BYTES)
50
65
  path = safe_svg_path(path)
51
66
  size = File.size(path)
52
67
  raise LimitError, "SVG exceeds #{max_bytes} bytes" if size > max_bytes
@@ -54,13 +69,7 @@ module SafeImage
54
69
  xml = File.binread(path, max_bytes + 1)
55
70
  raise LimitError, "SVG exceeds #{max_bytes} bytes" if xml.bytesize > max_bytes
56
71
  reject_unsafe_xml!(xml)
57
- doc = REXML::Document.new(xml)
58
- raise InvalidImageError, "SVG root required" unless doc.root&.name == "svg"
59
-
60
- validate_tree!(doc.root)
61
- doc
62
- rescue REXML::ParseException => e
63
- raise InvalidImageError, "invalid SVG: #{e.message}"
72
+ xml
64
73
  end
65
74
 
66
75
  def safe_svg_path(path)
@@ -110,23 +119,44 @@ module SafeImage
110
119
  [width.ceil, height.ceil]
111
120
  end
112
121
 
113
- def validate_tree!(root)
114
- counters = { elements: 0, attributes: 0 }
115
- validate_element!(root, depth: 0, counters: counters)
116
- end
117
-
118
- def validate_element!(element, depth:, counters:)
119
- raise LimitError, "SVG nesting exceeds #{MAX_SVG_DEPTH}" if depth > MAX_SVG_DEPTH
120
-
121
- counters[:elements] += 1
122
- raise LimitError, "SVG has too many elements" if counters[:elements] > MAX_SVG_ELEMENTS
123
-
124
- counters[:attributes] += element.attributes.length
125
- raise LimitError, "SVG has too many attributes" if counters[:attributes] > MAX_SVG_ATTRIBUTES
126
-
127
- element.elements.each do |child|
128
- validate_element!(child, depth: depth + 1, counters: counters)
122
+ # Streams the document with a pull parser, enforcing the structural caps as
123
+ # events arrive, so a hostile "millions of tiny elements" document is
124
+ # rejected at the cap without ever retaining the multi-million-object DOM
125
+ # that a parse-then-validate approach would build first. Returns the root
126
+ # element's name and its attributes hash.
127
+ def scan_svg!(xml)
128
+ parser = REXML::Parsers::PullParser.new(xml)
129
+ depth = -1
130
+ elements = 0
131
+ attributes = 0
132
+ root_name = nil
133
+ root_attributes = nil
134
+
135
+ while parser.has_next?
136
+ event = parser.pull
137
+ if event.start_element?
138
+ depth += 1
139
+ raise LimitError, "SVG nesting exceeds #{MAX_SVG_DEPTH}" if depth > MAX_SVG_DEPTH
140
+
141
+ elements += 1
142
+ raise LimitError, "SVG has too many elements" if elements > MAX_SVG_ELEMENTS
143
+
144
+ attributes += event[1].size
145
+ raise LimitError, "SVG has too many attributes" if attributes > MAX_SVG_ATTRIBUTES
146
+
147
+ if root_name.nil?
148
+ root_name = event[0]
149
+ root_attributes = event[1]
150
+ end
151
+ elsif event.end_element?
152
+ depth -= 1
153
+ end
129
154
  end
155
+
156
+ raise InvalidImageError, "SVG root required" unless root_name == "svg"
157
+ [root_name, root_attributes]
158
+ rescue REXML::ParseException => e
159
+ raise InvalidImageError, "invalid SVG: #{e.message}"
130
160
  end
131
161
  end
132
162
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeImage
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -20,6 +20,63 @@ module SafeImage
20
20
  Native.resize(input.to_s, output.to_s, scale, normalized_format(format), Integer(quality), max_pixels)
21
21
  end
22
22
 
23
+ def dominant_color(input, max_pixels: nil)
24
+ input = PathSafety.ensure_regular_file!(input).to_s
25
+ rgb = Native.dominant_color(input, max_pixels)
26
+ format("%02X%02X%02X", *rgb)
27
+ end
28
+
29
+ def frame_count(input, max_pixels: nil)
30
+ input = PathSafety.ensure_regular_file!(input).to_s
31
+ Native.pages(input, max_pixels)
32
+ end
33
+
34
+ def orientation(input, max_pixels: nil)
35
+ input = PathSafety.ensure_regular_file!(input).to_s
36
+ Native.orientation(input, max_pixels)
37
+ end
38
+
39
+ # Maps the public font tokens (shared with the ImageMagick backend) to
40
+ # Pango family names. DejaVu Sans additionally pins the font file bundled
41
+ # with the gem, so its rendering does not depend on host fonts.
42
+ BUNDLED_DEJAVU = File.expand_path("fonts/DejaVuSans.ttf", __dir__)
43
+ PANGO_FONTS = {
44
+ "DejaVu-Sans" => ["DejaVu Sans", BUNDLED_DEJAVU],
45
+ "NimbusSans-Regular" => ["Nimbus Sans", nil],
46
+ "Liberation-Sans" => ["Liberation Sans", nil],
47
+ "Arial" => ["Arial", nil],
48
+ "Helvetica" => ["Helvetica", nil],
49
+ "Adwaita-Sans" => ["Adwaita Sans", nil]
50
+ }.freeze
51
+
52
+ def letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")
53
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
54
+ output = PathSafety.ensure_safe_output_path!(output).to_s
55
+ size = Integer(size)
56
+ raise ArgumentError, "size must be 1..4096" unless (1..4096).cover?(size)
57
+ pointsize = Integer(pointsize)
58
+ raise ArgumentError, "pointsize must be 1..2000" unless (1..2000).cover?(pointsize)
59
+ rgb = Array(background_rgb).map { |value| Integer(value) }
60
+ unless rgb.length == 3 && rgb.all? { |value| (0..255).cover?(value) }
61
+ raise ArgumentError, "background_rgb must have three channels in 0..255"
62
+ end
63
+ family, fontfile = PANGO_FONTS.fetch(font.to_s) { raise ArgumentError, "unsupported font: #{font.to_s.inspect}" }
64
+ fontfile = nil unless fontfile && File.file?(fontfile)
65
+
66
+ # vips_text parses Pango markup, and the glyph derives from user input.
67
+ glyph = letter.to_s.each_grapheme_cluster.first.to_s.strip
68
+ markup = glyph.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
69
+
70
+ Native.letter_avatar(output, size, rgb[0], rgb[1], rgb[2], markup, "#{family} #{pointsize}", fontfile.to_s)
71
+ {
72
+ input_format: "generated",
73
+ output_format: "png",
74
+ width: size,
75
+ height: size,
76
+ duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
77
+ }
78
+ end
79
+
23
80
  def normalized_format(format)
24
81
  format = format.to_s.downcase
25
82
  format == "jpeg" ? "jpg" : format
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+
5
+ module SafeImage
6
+ # Minimal Fiddle binding for libvips. Instead of libvips' variadic C
7
+ # convenience API, operations are invoked through the fixed-signature
8
+ # GObject layer: vips_operation_new -> set properties as GValues ->
9
+ # vips_cache_operation_build -> read outputs -> unref. The invocation
10
+ # pattern is modelled on ruby-vips (MIT, Copyright (c) 2016 John Cupitt,
11
+ # https://github.com/libvips/ruby-vips), trimmed to exactly the operations
12
+ # SafeImage::Native performs — the function table below doubles as an
13
+ # operation allowlist.
14
+ module VipsGlue
15
+ GVALUE_SIZE = 24
16
+ GVALUE_ZERO = ("\0" * GVALUE_SIZE).freeze
17
+ # Public, decades-stable GParamSpec ABI: GTypeInstance(8) + name(8) +
18
+ # flags(4 + padding) puts value_type at byte 24.
19
+ PSPEC_VALUE_TYPE_OFFSET = 24
20
+
21
+ LIBRARY_CANDIDATES = %w[libvips.so.42 libvips.42.dylib libvips.dylib libvips.so].freeze
22
+
23
+ TYPE = {
24
+ void: Fiddle::TYPE_VOID,
25
+ int: Fiddle::TYPE_INT,
26
+ double: Fiddle::TYPE_DOUBLE,
27
+ size_t: Fiddle::TYPE_SIZE_T,
28
+ ptr: Fiddle::TYPE_VOIDP
29
+ }.freeze
30
+
31
+ # Fixed-signature entry points only; no varargs anywhere. The g_*
32
+ # symbols resolve through the libvips handle via its GLib dependency.
33
+ SIGNATURES = {
34
+ vips_init: [%i[ptr], :int],
35
+ vips_version: [%i[int], :int],
36
+ vips_error_buffer: [[], :ptr],
37
+ vips_error_clear: [[], :void],
38
+ vips_block_untrusted_set: [%i[int], :void],
39
+ vips_operation_block_set: [%i[ptr int], :void],
40
+ vips_concurrency_set: [%i[int], :void],
41
+ vips_cache_set_max: [%i[int], :void],
42
+ vips_cache_set_max_mem: [%i[size_t], :void],
43
+ vips_cache_set_max_files: [%i[int], :void],
44
+ vips_type_find: [%i[ptr ptr], :size_t],
45
+ vips_enum_from_nick: [%i[ptr size_t ptr], :int],
46
+ vips_operation_new: [%i[ptr], :ptr],
47
+ vips_cache_operation_build: [%i[ptr], :ptr],
48
+ vips_object_unref_outputs: [%i[ptr], :void],
49
+ vips_value_set_array_double: [%i[ptr ptr int], :void],
50
+ vips_image_get_width: [%i[ptr], :int],
51
+ vips_image_get_height: [%i[ptr], :int],
52
+ vips_image_get_bands: [%i[ptr], :int],
53
+ vips_image_get_n_pages: [%i[ptr], :int],
54
+ vips_image_get_orientation: [%i[ptr], :int],
55
+ vips_image_hasalpha: [%i[ptr], :int],
56
+ vips_colourspace_issupported: [%i[ptr], :int],
57
+ vips_image_new_from_memory_copy: [%i[ptr size_t int int int int], :ptr],
58
+ vips_image_write_to_memory: [%i[ptr ptr], :ptr],
59
+ g_object_ref: [%i[ptr], :ptr],
60
+ g_object_unref: [%i[ptr], :void],
61
+ g_object_set_property: [%i[ptr ptr ptr], :void],
62
+ g_object_get_property: [%i[ptr ptr ptr], :void],
63
+ g_object_class_find_property: [%i[ptr ptr], :ptr],
64
+ g_value_init: [%i[ptr size_t], :ptr],
65
+ g_value_unset: [%i[ptr], :void],
66
+ g_value_set_boolean: [%i[ptr int], :void],
67
+ g_value_set_int: [%i[ptr int], :void],
68
+ g_value_set_double: [%i[ptr double], :void],
69
+ g_value_set_string: [%i[ptr ptr], :void],
70
+ g_value_set_enum: [%i[ptr int], :void],
71
+ g_value_set_flags: [%i[ptr int], :void],
72
+ g_value_set_object: [%i[ptr ptr], :void],
73
+ g_value_get_object: [%i[ptr], :ptr],
74
+ g_type_fundamental: [%i[size_t], :size_t],
75
+ g_type_from_name: [%i[ptr], :size_t],
76
+ g_free: [%i[ptr], :void]
77
+ }.freeze
78
+
79
+ @initialized = false
80
+ @load_error = nil
81
+ @init_mutex = Mutex.new
82
+
83
+ class << self
84
+ def init!
85
+ return if @initialized
86
+ raise @load_error if @load_error
87
+
88
+ @init_mutex.synchronize do
89
+ next if @initialized
90
+ raise @load_error if @load_error
91
+
92
+ handle = open_library
93
+ @functions = SIGNATURES.to_h do |name, (args, ret)|
94
+ address = handle[name.to_s]
95
+ [name, Fiddle::Function.new(address, args.map { |t| TYPE.fetch(t) }, TYPE.fetch(ret))]
96
+ end
97
+
98
+ silence_vips_log!
99
+ raise Error, "vips_init failed: #{error_message}" if c(:vips_init, "safe_image") != 0
100
+
101
+ major = c(:vips_version, 0)
102
+ minor = c(:vips_version, 1)
103
+ @version = [major, minor]
104
+ raise Error, "libvips >= 8.13 is required (found #{major}.#{minor})" if (@version <=> [8, 13]).negative?
105
+
106
+ harden!
107
+ resolve_gtypes!
108
+ @initialized = true
109
+ end
110
+ end
111
+
112
+ def version
113
+ init!
114
+ @version
115
+ end
116
+
117
+ # True when libvips loaded (or loads) successfully. A load failure is
118
+ # memoized; the gem keeps working through the ImageMagick paths.
119
+ def available?
120
+ init!
121
+ true
122
+ rescue VipsUnavailableError
123
+ false
124
+ end
125
+
126
+ # Calls a bound C function by name.
127
+ def c(name, *args)
128
+ @functions.fetch(name).call(*args)
129
+ end
130
+
131
+ def error!
132
+ message = error_message
133
+ c(:vips_error_clear)
134
+ raise InvalidImageError, message
135
+ end
136
+
137
+ def error_message
138
+ ptr = c(:vips_error_buffer)
139
+ message = ptr.null? ? "" : ptr.to_s
140
+ message.empty? ? "libvips error" : message.strip
141
+ end
142
+
143
+ def type_find?(nickname)
144
+ init!
145
+ !c(:vips_type_find, "VipsOperation", nickname).zero?
146
+ end
147
+
148
+ def unref(image_ptr)
149
+ c(:g_object_unref, image_ptr) if image_ptr && !image_ptr.null?
150
+ end
151
+
152
+ # Tracks every acquired VipsImage pointer and releases all of them when
153
+ # the block exits, success or failure. Pipelines are strictly linear,
154
+ # so deterministic unref in reverse order is sufficient.
155
+ def with_images
156
+ acquired = []
157
+ track = lambda do |ptr|
158
+ acquired << ptr
159
+ ptr
160
+ end
161
+ init!
162
+ yield track
163
+ ensure
164
+ acquired.reverse_each { |ptr| unref(ptr) }
165
+ end
166
+
167
+ # Invokes one vips operation. Property values are converted according
168
+ # to the property's GType: booleans, ints, doubles, strings, enums
169
+ # (given as nick strings), flags, double arrays and VipsImage pointers.
170
+ # Returns the named output image pointer (caller owns one reference),
171
+ # or nil when output is nil (savers).
172
+ def operation(nickname, inputs, output: "out")
173
+ op = c(:vips_operation_new, nickname)
174
+ raise UnsupportedFormatError, "unknown vips operation: #{nickname}" if op.null?
175
+
176
+ begin
177
+ inputs.each { |name, value| set_property(op, name.to_s, value) }
178
+
179
+ built = c(:vips_cache_operation_build, op)
180
+ if built.null?
181
+ c(:vips_object_unref_outputs, op)
182
+ error!
183
+ end
184
+
185
+ begin
186
+ output ? image_output(built, output) : nil
187
+ ensure
188
+ c(:vips_object_unref_outputs, built)
189
+ c(:g_object_unref, built)
190
+ end
191
+ ensure
192
+ c(:g_object_unref, op)
193
+ end
194
+ end
195
+
196
+ def width(image_ptr) = c(:vips_image_get_width, image_ptr)
197
+ def height(image_ptr) = c(:vips_image_get_height, image_ptr)
198
+ def bands(image_ptr) = c(:vips_image_get_bands, image_ptr)
199
+ def pages(image_ptr) = c(:vips_image_get_n_pages, image_ptr)
200
+ def orientation(image_ptr) = c(:vips_image_get_orientation, image_ptr)
201
+ def alpha?(image_ptr) = !c(:vips_image_hasalpha, image_ptr).zero?
202
+ def colourspace_supported?(image_ptr) = !c(:vips_colourspace_issupported, image_ptr).zero?
203
+
204
+ def image_from_memory(bytes, width, height, bands, format_number)
205
+ init!
206
+ ptr = c(:vips_image_new_from_memory_copy, bytes, bytes.bytesize, width, height, bands, format_number)
207
+ error! if ptr.null?
208
+ ptr
209
+ end
210
+
211
+ # Copies the image's pixel data out as a binary string (used to read
212
+ # the tiny vips_stats matrix without binding the variadic getpoint).
213
+ def image_bytes(image_ptr)
214
+ size_out = Fiddle::Pointer.malloc(Fiddle::SIZEOF_SIZE_T, Fiddle::RUBY_FREE)
215
+ buffer = c(:vips_image_write_to_memory, image_ptr, size_out)
216
+ error! if buffer.null?
217
+ begin
218
+ buffer[0, size_out[0, Fiddle::SIZEOF_SIZE_T].unpack1("J")]
219
+ ensure
220
+ c(:g_free, buffer)
221
+ end
222
+ end
223
+
224
+ private
225
+
226
+ def open_library
227
+ errors = []
228
+ override = ENV["SAFE_IMAGE_LIBVIPS"]
229
+ # An explicit override is authoritative: no fallback to the default
230
+ # names (this also lets tests simulate a vips-less host).
231
+ candidates = override && !override.empty? ? [override] : LIBRARY_CANDIDATES
232
+ candidates.each do |name|
233
+ return Fiddle.dlopen(name)
234
+ rescue Fiddle::DLError => e
235
+ errors << e.message
236
+ end
237
+ @load_error = VipsUnavailableError.new(
238
+ "could not load libvips (install the libvips runtime package, e.g. libvips42 on Debian): #{errors.join("; ")}"
239
+ )
240
+ raise @load_error
241
+ end
242
+
243
+ def harden!
244
+ # Block operations libvips tags unsafe for untrusted input, plus the
245
+ # ImageMagick loader classes by name. The libjxl loader/saver are
246
+ # deliberately re-enabled: JPEG XL is part of the supported input
247
+ # surface and inputs still pass extension routing and pixel caps.
248
+ c(:vips_block_untrusted_set, 1)
249
+ c(:vips_operation_block_set, "VipsForeignLoadMagick", 1)
250
+ c(:vips_operation_block_set, "VipsForeignLoadMagick6", 1)
251
+ c(:vips_operation_block_set, "VipsForeignLoadMagick7", 1)
252
+ c(:vips_operation_block_set, "VipsForeignLoadJxl", 0)
253
+ c(:vips_operation_block_set, "VipsForeignSaveJxl", 0)
254
+
255
+ # Keep the embedded path predictable and bounded.
256
+ c(:vips_concurrency_set, 1)
257
+ c(:vips_cache_set_max, 0)
258
+ c(:vips_cache_set_max_mem, 0)
259
+ c(:vips_cache_set_max_files, 0)
260
+ end
261
+
262
+ # Hostile input is expected here; libvips' GLib warnings about it (for
263
+ # example "Not a PNG file") would otherwise litter stderr on every
264
+ # rejected upload. Failures still surface as exceptions with the same
265
+ # detail. Setting VIPS_WARNING makes vips_init install its own C-level
266
+ # no-op log handler — this must NOT be done with a Ruby callback, which
267
+ # libvips may invoke from non-Ruby threads and crash the VM. Set
268
+ # SAFE_IMAGE_VIPS_WARNINGS=1 to keep the warnings.
269
+ def silence_vips_log!
270
+ if ENV["SAFE_IMAGE_VIPS_WARNINGS"] == "1"
271
+ # VIPS_WARNING may be inherited from a parent process where this
272
+ # gem set it; the explicit opt-in to warnings wins.
273
+ ENV.delete("VIPS_WARNING")
274
+ else
275
+ ENV["VIPS_WARNING"] ||= "1"
276
+ end
277
+ end
278
+
279
+ def resolve_gtypes!
280
+ @gtype = {}
281
+ {
282
+ boolean: "gboolean",
283
+ int: "gint",
284
+ uint64: "guint64",
285
+ double: "gdouble",
286
+ string: "gchararray",
287
+ enum: "GEnum",
288
+ flags: "GFlags",
289
+ boxed: "GBoxed",
290
+ object: "GObject",
291
+ image: "VipsImage",
292
+ array_double: "VipsArrayDouble"
293
+ }.each do |key, name|
294
+ gtype = c(:g_type_from_name, name)
295
+ raise Error, "GType #{name} is not registered" if gtype.zero?
296
+ @gtype[key] = gtype
297
+ end
298
+ end
299
+
300
+ def with_gvalue(gtype)
301
+ buffer = Fiddle::Pointer.malloc(GVALUE_SIZE, Fiddle::RUBY_FREE)
302
+ buffer[0, GVALUE_SIZE] = GVALUE_ZERO
303
+ c(:g_value_init, buffer, gtype)
304
+ begin
305
+ yield buffer
306
+ ensure
307
+ c(:g_value_unset, buffer)
308
+ end
309
+ end
310
+
311
+ def set_property(object_ptr, name, value)
312
+ name = name.tr("_", "-")
313
+ klass = object_ptr[0, Fiddle::SIZEOF_VOIDP].unpack1("J")
314
+ pspec = c(:g_object_class_find_property, klass, name)
315
+ raise Error, "vips operation has no argument #{name.inspect}" if pspec.null?
316
+
317
+ value_type = pspec[PSPEC_VALUE_TYPE_OFFSET, Fiddle::SIZEOF_VOIDP].unpack1("J")
318
+ with_gvalue(value_type) do |gvalue|
319
+ write_gvalue(gvalue, value_type, name, value)
320
+ c(:g_object_set_property, object_ptr, name, gvalue)
321
+ end
322
+ end
323
+
324
+ def write_gvalue(gvalue, value_type, name, value)
325
+ case c(:g_type_fundamental, value_type)
326
+ when @gtype[:boolean] then c(:g_value_set_boolean, gvalue, value ? 1 : 0)
327
+ when @gtype[:int] then c(:g_value_set_int, gvalue, Integer(value))
328
+ when @gtype[:double] then c(:g_value_set_double, gvalue, Float(value))
329
+ when @gtype[:string] then c(:g_value_set_string, gvalue, value.to_s)
330
+ when @gtype[:enum] then c(:g_value_set_enum, gvalue, enum_value(value_type, value))
331
+ when @gtype[:flags] then c(:g_value_set_flags, gvalue, Integer(value))
332
+ when @gtype[:object] then c(:g_value_set_object, gvalue, value)
333
+ when @gtype[:boxed]
334
+ raise Error, "unsupported boxed type for #{name.inspect}" unless value_type == @gtype[:array_double]
335
+ doubles = Array(value).map { |v| Float(v) }
336
+ c(:vips_value_set_array_double, gvalue, doubles.pack("d*"), doubles.length)
337
+ else
338
+ raise Error, "unsupported GType for vips argument #{name.inspect}"
339
+ end
340
+ end
341
+
342
+ def enum_value(value_type, value)
343
+ return Integer(value) if value.is_a?(Integer)
344
+
345
+ number = c(:vips_enum_from_nick, "safe_image", value_type, value.to_s)
346
+ error! if number.negative?
347
+ number
348
+ end
349
+
350
+ def image_output(op_ptr, name)
351
+ with_gvalue(@gtype[:image]) do |gvalue|
352
+ c(:g_object_get_property, op_ptr, name, gvalue)
353
+ ptr = c(:g_value_get_object, gvalue)
354
+ raise InvalidImageError, "vips operation produced no output" if ptr.null?
355
+ c(:g_object_ref, ptr)
356
+ ptr
357
+ end
358
+ end
359
+ end
360
+ end
361
+ end