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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +158 -0
- data/README.md +475 -281
- data/SECURITY.md +27 -7
- data/lib/safe_image/discourse_compat.rb +267 -98
- data/lib/safe_image/fonts/DEJAVU-LICENSE +187 -0
- data/lib/safe_image/fonts/DejaVuSans.ttf +0 -0
- data/lib/safe_image/ico.rb +286 -0
- data/lib/safe_image/image_magick_backend.rb +39 -3
- data/lib/safe_image/imagemagick_policy/policy.xml +8 -1
- data/lib/safe_image/jpegli_backend.rb +3 -1
- data/lib/safe_image/native.rb +371 -1
- data/lib/safe_image/processor.rb +23 -69
- data/lib/safe_image/remote.rb +15 -3
- data/lib/safe_image/runner.rb +1 -1
- data/lib/safe_image/sandbox.rb +42 -16
- data/lib/safe_image/svg_metadata.rb +59 -29
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/vips_backend.rb +57 -0
- data/lib/safe_image/vips_glue.rb +361 -0
- data/lib/safe_image.rb +143 -36
- metadata +30 -14
- data/ext/safe_image_native/extconf.rb +0 -8
- data/ext/safe_image_native/safe_image_native.c +0 -392
data/lib/safe_image/sandbox.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
data/lib/safe_image/version.rb
CHANGED
|
@@ -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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
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
|