safe_image 0.3.0 → 0.5.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -12
  3. data/README.md +140 -287
  4. data/SECURITY.md +22 -11
  5. data/docs/architecture.md +63 -0
  6. data/ext/safe_image_vips_helper/extconf.rb +43 -0
  7. data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
  8. data/lib/safe_image/api/metadata.rb +85 -0
  9. data/lib/safe_image/api/transform.rb +152 -0
  10. data/lib/safe_image/backend_label.rb +24 -0
  11. data/lib/safe_image/formats.rb +96 -0
  12. data/lib/safe_image/ico.rb +42 -40
  13. data/lib/safe_image/image_magick_backend.rb +219 -162
  14. data/lib/safe_image/jpegli_backend.rb +64 -44
  15. data/lib/safe_image/metadata_operations.rb +155 -0
  16. data/lib/safe_image/native.rb +96 -290
  17. data/lib/safe_image/native_helper.rb +281 -0
  18. data/lib/safe_image/operation_backends/base.rb +83 -0
  19. data/lib/safe_image/operation_backends/image_magick.rb +123 -0
  20. data/lib/safe_image/operation_backends/vips.rb +251 -0
  21. data/lib/safe_image/operation_backends.rb +27 -0
  22. data/lib/safe_image/operation_set.rb +22 -0
  23. data/lib/safe_image/optimizer.rb +225 -98
  24. data/lib/safe_image/path_safety.rb +11 -0
  25. data/lib/safe_image/processor.rb +122 -85
  26. data/lib/safe_image/quality_defaults.rb +23 -0
  27. data/lib/safe_image/remote.rb +248 -144
  28. data/lib/safe_image/result.rb +71 -23
  29. data/lib/safe_image/runner.rb +60 -23
  30. data/lib/safe_image/sandbox.rb +44 -218
  31. data/lib/safe_image/staged_output.rb +44 -0
  32. data/lib/safe_image/svg_metadata.rb +74 -69
  33. data/lib/safe_image/transform_operations.rb +139 -0
  34. data/lib/safe_image/version.rb +1 -1
  35. data/lib/safe_image/vips_backend.rb +13 -12
  36. data/lib/safe_image.rb +62 -306
  37. metadata +43 -37
  38. data/lib/safe_image/discourse_compat.rb +0 -441
  39. data/lib/safe_image/svg_css.rb +0 -314
  40. data/lib/safe_image/svg_sanitizer.rb +0 -583
  41. data/lib/safe_image/vips_glue.rb +0 -361
  42. data/lib/safe_image/zygote.rb +0 -619
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rbconfig"
5
+ require "tmpdir"
6
+
7
+ module SafeImage
8
+ module NativeHelper
9
+ module_function
10
+
11
+ DEFAULT_HELPER = File.expand_path("safe_image_vips_helper", __dir__).freeze
12
+ HELPER = ENV.fetch("SAFE_IMAGE_VIPS_HELPER", DEFAULT_HELPER).freeze
13
+ STDERR_TAIL_BYTES = 2000
14
+
15
+ def available?
16
+ verify!
17
+ true
18
+ rescue Error, CommandError, LoadError
19
+ false
20
+ end
21
+
22
+ def ensure_available!
23
+ return if File.executable?(HELPER)
24
+
25
+ raise VipsUnavailableError, "compiled safe_image_vips_helper is missing or not executable: #{HELPER}"
26
+ end
27
+
28
+ def verify!
29
+ return true if @verified
30
+
31
+ call!("version", sandbox: false)
32
+ @verified = true
33
+ end
34
+
35
+ def probe(path, max_pixels)
36
+ call!("probe", input: path, max_pixels: max_pixels)
37
+ end
38
+
39
+ def thumbnail(input, output, width, height, format, quality, max_pixels)
40
+ call!(
41
+ "thumbnail",
42
+ input: input,
43
+ output: output,
44
+ width: width,
45
+ height: height,
46
+ format: format,
47
+ quality: quality,
48
+ max_pixels: max_pixels
49
+ )
50
+ end
51
+
52
+ def resize(input, output, scale, format, quality, max_pixels)
53
+ call!(
54
+ "resize",
55
+ input: input,
56
+ output: output,
57
+ scale: scale,
58
+ format: format,
59
+ quality: quality,
60
+ max_pixels: max_pixels
61
+ )
62
+ end
63
+
64
+ def crop_north(input, output, width, height, format, quality, max_pixels)
65
+ call!(
66
+ "crop-north",
67
+ input: input,
68
+ output: output,
69
+ width: width,
70
+ height: height,
71
+ format: format,
72
+ quality: quality,
73
+ max_pixels: max_pixels
74
+ )
75
+ end
76
+
77
+ def convert(input, output, format, quality, max_pixels)
78
+ call!("convert", input: input, output: output, format: format, quality: quality, max_pixels: max_pixels)
79
+ end
80
+
81
+ def png_from_rgba(raw_input, width, height, output)
82
+ call!("png-from-rgba", raw_input: raw_input, width: width, height: height, output: output)
83
+ end
84
+
85
+ def letter_avatar(output, size, red, green, blue, markup, font, fontfile)
86
+ call!(
87
+ "letter-avatar",
88
+ output: output,
89
+ size: size,
90
+ red: red,
91
+ green: green,
92
+ blue: blue,
93
+ markup: markup,
94
+ font: font,
95
+ fontfile: fontfile
96
+ )
97
+ end
98
+
99
+ def dominant_color(path, max_pixels)
100
+ call!("dominant-color", input: path, max_pixels: max_pixels).fetch(:value)
101
+ end
102
+
103
+ def pages(path, max_pixels)
104
+ call!("pages", input: path, max_pixels: max_pixels).fetch(:value)
105
+ end
106
+
107
+ def orientation(path, max_pixels)
108
+ call!("orientation", input: path, max_pixels: max_pixels).fetch(:value)
109
+ end
110
+
111
+ def call!(command, sandbox: SafeImage.sandbox?, **options)
112
+ ensure_available!
113
+
114
+ Dir.mktmpdir("safe-image-vips-helper-") do |tmpdir|
115
+ response = File.join(tmpdir, "response.json")
116
+ argv = [HELPER, command, "--response", response]
117
+ options.each do |key, value|
118
+ next if value.nil?
119
+
120
+ argv << "--#{key.to_s.tr("_", "-")}" << value.to_s
121
+ end
122
+
123
+ stdout = stderr = nil
124
+ status = nil
125
+ if sandbox
126
+ begin
127
+ stdout, stderr, status = run_sandboxed!(argv, tmpdir, options)
128
+ rescue Sandbox.landlock_command_error => e
129
+ stdout = e.stdout
130
+ stderr = e.stderr
131
+ status = e.status
132
+ payload = read_response(response, stderr: stderr)
133
+ raise_helper_error(payload, status, command, stderr: stderr, stdout: stdout)
134
+ end
135
+ else
136
+ begin
137
+ stdout, stderr, status = run_process!(argv, tmpdir)
138
+ rescue CommandError => e
139
+ stdout = e.stdout
140
+ stderr = e.stderr
141
+ status = e.status
142
+ payload = read_response(response, stderr: stderr)
143
+ raise_helper_error(payload, status, command, stderr: stderr, stdout: stdout)
144
+ end
145
+ end
146
+
147
+ payload = read_response(response, stderr: stderr)
148
+ helper_failed = status.respond_to?(:success?) ? !status.success? : false
149
+ raise_helper_error(payload, status, command, stderr: stderr, stdout: stdout) if helper_failed || !payload[:ok]
150
+ payload.reject { |key, _| key == :ok }
151
+ end
152
+ rescue LoadError
153
+ raise Error, "landlock sandbox requested but the landlock gem is unavailable"
154
+ end
155
+
156
+ def run_process!(argv, tmpdir)
157
+ stdout, stderr = Runner.run_process!(argv, helper_env(tmpdir), timeout: Runner::DEFAULT_TIMEOUT)
158
+ [stdout, stderr, nil]
159
+ end
160
+
161
+ def run_sandboxed!(argv, tmpdir, options)
162
+ require "landlock"
163
+
164
+ result =
165
+ Sandbox.landlock_capture!(
166
+ argv,
167
+ read:
168
+ Sandbox.existing_paths([*Sandbox.default_read_paths, *Sandbox.runtime_read_paths, *read_paths(options)]),
169
+ write: Sandbox.existing_paths([tmpdir, *write_paths(options)]),
170
+ execute: Sandbox.existing_paths([File.dirname(HELPER), *Sandbox.default_execute_paths]),
171
+ env: helper_env(tmpdir),
172
+ unsetenv_others: true,
173
+ bind_tcp: landlock_abi >= 4 ? [1] : [],
174
+ scope: landlock_abi >= 6 ? %i[abstract_unix_socket signal] : [],
175
+ timeout: Runner::DEFAULT_TIMEOUT,
176
+ rlimits: Sandbox::DEFAULT_RLIMITS,
177
+ seccomp_deny_network: true,
178
+ max_output_bytes: Runner::MAX_OUTPUT_BYTES,
179
+ truncate_output: false
180
+ )
181
+ stdout = result.stdout if result.respond_to?(:stdout)
182
+ stderr = result.stderr if result.respond_to?(:stderr)
183
+ status = result.status if result.respond_to?(:status)
184
+ [stdout, stderr, status]
185
+ end
186
+
187
+ def helper_env(tmpdir)
188
+ Runner.command_env(tmpdir).merge("SAFE_IMAGE_SANDBOX_CHILD" => "1")
189
+ end
190
+
191
+ def landlock_abi
192
+ @landlock_abi ||= Landlock.abi_version
193
+ end
194
+
195
+ def tail(value)
196
+ text = value.to_s
197
+ return nil if text.empty?
198
+
199
+ text.bytesize > STDERR_TAIL_BYTES ? text.byteslice(-STDERR_TAIL_BYTES, STDERR_TAIL_BYTES) : text
200
+ end
201
+
202
+ def read_response(path, stderr: nil)
203
+ JSON.parse(File.read(path), symbolize_names: true)
204
+ rescue Errno::ENOENT
205
+ {
206
+ ok: false,
207
+ error: "CommandError",
208
+ message: "native vips helper did not write a response",
209
+ stderr_tail: tail(stderr)
210
+ }
211
+ rescue JSON::ParserError => e
212
+ {
213
+ ok: false,
214
+ error: "CommandError",
215
+ message: "native vips helper wrote invalid JSON: #{e.message}",
216
+ stderr_tail: tail(stderr)
217
+ }
218
+ end
219
+
220
+ def raise_helper_error(payload, status, command, stderr: nil, stdout: nil)
221
+ message =
222
+ (
223
+ if payload[:message].to_s.empty?
224
+ "native vips helper failed with status #{exit_status(status).inspect}"
225
+ else
226
+ payload[:message]
227
+ end
228
+ )
229
+ original_class = payload[:error].to_s
230
+ stderr_tail = payload[:stderr_tail] || tail(stderr)
231
+ case original_class
232
+ when "LimitError"
233
+ raise LimitError.new(message, original_error_class: original_class, stderr_tail: stderr_tail)
234
+ when "UnsupportedFormatError"
235
+ raise UnsupportedFormatError.new(message, original_error_class: original_class, stderr_tail: stderr_tail)
236
+ when "VipsUnavailableError"
237
+ raise VipsUnavailableError.new(message, original_error_class: original_class, stderr_tail: stderr_tail)
238
+ when "ArgumentError"
239
+ raise ArgumentError, append_helper_detail(message, original_class, stderr_tail)
240
+ when "InvalidImageError", ""
241
+ raise InvalidImageError.new(message, original_error_class: original_class, stderr_tail: stderr_tail)
242
+ else
243
+ raise CommandError.new(
244
+ "native vips helper downgraded unknown error class #{original_class.inspect}: #{message}",
245
+ command: [HELPER, command],
246
+ status: exit_status(status),
247
+ stdout: stdout.to_s,
248
+ stderr: stderr.to_s,
249
+ category: :native_helper,
250
+ operation: command,
251
+ original_error_class: original_class,
252
+ stderr_tail: stderr_tail
253
+ )
254
+ end
255
+ end
256
+
257
+ def exit_status(status)
258
+ status.respond_to?(:exitstatus) ? status.exitstatus : status
259
+ end
260
+
261
+ def append_helper_detail(message, _original_class, stderr_tail)
262
+ return message if stderr_tail.nil? || stderr_tail.empty?
263
+
264
+ "#{message} (native vips helper stderr tail: #{stderr_tail})"
265
+ end
266
+
267
+ def read_paths(options)
268
+ [options[:input], options[:raw_input], options[:fontfile]].compact.reject(&:empty?)
269
+ end
270
+
271
+ def write_paths(options)
272
+ output = options[:output]
273
+ return [] unless output
274
+
275
+ expanded = File.expand_path(output)
276
+ paths = [File.dirname(expanded)]
277
+ paths << expanded if File.exist?(expanded)
278
+ paths
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module SafeImage
6
+ module OperationBackends
7
+ # Shared operation-strategy plumbing: path normalization, max-pixel config,
8
+ # optimizer post-processing and uniform Result construction.
9
+ class Base
10
+ attr_reader :config
11
+
12
+ def initialize(config:)
13
+ @config = config
14
+ end
15
+
16
+ def frame_count(path, max_pixels: nil)
17
+ max_pixels = resolved_max_pixels(max_pixels)
18
+ # ico directories are counted by the pure-Ruby parser on either backend;
19
+ # everything else is a header-only count.
20
+ if File.extname(PathSafety.local_path(path)).downcase == ".ico"
21
+ return Ico.frame_count(path, max_pixels: max_pixels)
22
+ end
23
+
24
+ backend_frame_count(path, max_pixels: max_pixels)
25
+ end
26
+
27
+ private
28
+
29
+ def input_output!(input, output)
30
+ input, output = PathSafety.ensure_distinct_file_paths!(input, output)
31
+ [input.to_s, output.to_s]
32
+ end
33
+
34
+ def safe_output!(output)
35
+ PathSafety.ensure_safe_output_path!(output).to_s
36
+ end
37
+
38
+ def resolved_max_pixels(max_pixels)
39
+ SafeImage.resolved_max_pixels(max_pixels, config: config)
40
+ end
41
+
42
+ # Post-processing applies only to the formats the optimizer tools
43
+ # understand; other outputs (gif, jxl, ...) skip the pass.
44
+ def optimize_output(output, quality)
45
+ format = Formats.extension(output)
46
+ return unless Formats.optimizable_output?(format)
47
+
48
+ write_through_tempfile(output) do |tmp_path|
49
+ Optimizer.optimize(
50
+ input: output,
51
+ output: tmp_path,
52
+ mode: :lossless,
53
+ strip_metadata: true,
54
+ quality: quality,
55
+ assume_upright: true
56
+ )
57
+ end
58
+ end
59
+
60
+ # Writes via a sibling tempfile and renames into place so callers never
61
+ # observe a partially-written destination.
62
+ def write_through_tempfile(output)
63
+ StagedOutput.replace(output, suffix: ".safe-image#{File.extname(output)}") { |tmp_path| yield tmp_path }
64
+ end
65
+
66
+ def result_from_info(input, output, info, backend, tier: nil, optimizer: nil)
67
+ Result.build(
68
+ input: input,
69
+ output: output,
70
+ input_format: info.fetch(:input_format),
71
+ output_format: info.fetch(:output_format),
72
+ width: info.fetch(:width),
73
+ height: info.fetch(:height),
74
+ backend: backend,
75
+ encoder: info[:encoder],
76
+ duration_ms: info.fetch(:duration_ms),
77
+ optimizer: optimizer,
78
+ tier: tier
79
+ )
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module SafeImage
6
+ module OperationBackends
7
+ # ImageMagick operation orchestration. Inputs/outputs still pass the shared
8
+ # path safety checks before the lower backend builds argv arrays with coder:
9
+ # prefixes and the restrictive policy.xml.
10
+ class ImageMagick < Base
11
+ def resize(input:, output:, width:, height:, quality:, optimize:, max_pixels:, chroma_subsampling: nil)
12
+ input, output = input_output!(input, output)
13
+ probe = operation_probe(input, max_pixels: resolved_max_pixels(max_pixels))
14
+ output = safe_output!(output)
15
+ info =
16
+ ImageMagickBackend.thumbnail(
17
+ input: probe.input,
18
+ output: output,
19
+ width: width,
20
+ height: height,
21
+ format: Formats.extension(output),
22
+ quality: quality
23
+ )
24
+ optimize_output(output, quality) if optimize
25
+ result_from_info(probe.input, output, info, :imagemagick, tier: :resize)
26
+ end
27
+
28
+ def crop(input:, output:, width:, height:, quality:, optimize:, max_pixels:, chroma_subsampling: nil)
29
+ input, output = input_output!(input, output)
30
+ probe = operation_probe(input, max_pixels: resolved_max_pixels(max_pixels))
31
+ output = safe_output!(output)
32
+ info =
33
+ ImageMagickBackend.resize_like(
34
+ input: probe.input,
35
+ output: output,
36
+ width: width,
37
+ height: height,
38
+ format: Formats.extension(output),
39
+ quality: quality,
40
+ crop: :north
41
+ )
42
+ optimize_output(output, quality) if optimize
43
+ result_from_info(probe.input, output, info, :imagemagick, tier: :crop)
44
+ end
45
+
46
+ def downsize(input:, output:, dimensions:, optimize:, max_pixels:, quality: nil, chroma_subsampling: nil)
47
+ input, output = input_output!(input, output)
48
+ probe = operation_probe(input, max_pixels: resolved_max_pixels(max_pixels))
49
+ output = safe_output!(output)
50
+ info =
51
+ ImageMagickBackend.downsize(
52
+ input: probe.input,
53
+ output: output,
54
+ dimensions: dimensions,
55
+ format: Formats.extension(output)
56
+ )
57
+ optimize_output(output, nil) if optimize
58
+ result_from_info(probe.input, output, info, :imagemagick, tier: :downsize)
59
+ end
60
+
61
+ def convert(input:, output:, format:, quality:, optimize:, max_pixels:, chroma_subsampling: nil)
62
+ input, output = input_output!(input, output)
63
+ probe = operation_probe(input, max_pixels: resolved_max_pixels(max_pixels))
64
+ normalized_format = Formats.normalize(format)
65
+ info = ImageMagickBackend.convert(input: probe.input, output: output, format: format, quality: quality)
66
+ optimize_output(output, normalized_format == "jpg" ? quality : nil) if optimize
67
+ result_from_info(probe.input, output, info, :imagemagick, tier: :convert)
68
+ end
69
+
70
+ def fix_orientation(input:, output:, max_pixels:, quality: nil)
71
+ input, output = input_output!(input, output)
72
+ probe = operation_probe(input, max_pixels: resolved_max_pixels(max_pixels))
73
+ info = ImageMagickBackend.fix_orientation(input: probe.input, output: output)
74
+ result_from_info(probe.input, output, info, :imagemagick, tier: :fix_orientation)
75
+ end
76
+
77
+ def convert_favicon_to_png(input:, output:, optimize:, max_pixels: nil)
78
+ input, output = input_output!(input, output)
79
+ info = ImageMagickBackend.convert_ico_to_png(input: Pathname.new(input).expand_path.to_s, output: output)
80
+ optimize_output(output, nil) if optimize
81
+ result_from_info(input, output, info, :imagemagick, tier: :convert_favicon)
82
+ end
83
+
84
+ def letter_avatar(output:, size:, background_rgb:, letter:, pointsize:, font:)
85
+ output = safe_output!(output)
86
+ request = {
87
+ output: output,
88
+ size: size,
89
+ background_rgb: background_rgb,
90
+ letter: letter,
91
+ pointsize: pointsize,
92
+ font: font
93
+ }
94
+ result_from_info(
95
+ "generated",
96
+ output,
97
+ ImageMagickBackend.letter_avatar(**request),
98
+ :imagemagick,
99
+ tier: :letter_avatar
100
+ )
101
+ end
102
+
103
+ private
104
+
105
+ def operation_probe(path, max_pixels:)
106
+ path = Pathname.new(path).expand_path.to_s
107
+ info = ImageMagickBackend.probe(path, max_pixels: max_pixels)
108
+ Result.metadata(
109
+ input: path,
110
+ input_format: info.fetch(:input_format),
111
+ width: info.fetch(:width),
112
+ height: info.fetch(:height),
113
+ backend: :imagemagick,
114
+ duration_ms: info.fetch(:duration_ms)
115
+ )
116
+ end
117
+
118
+ def backend_frame_count(path, max_pixels:)
119
+ ImageMagickBackend.frame_count(path, max_pixels: max_pixels)
120
+ end
121
+ end
122
+ end
123
+ end