safe_image 0.3.0 → 0.4.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 +21 -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,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeImage
4
+ module API
5
+ # Public read-only metadata operations. Methods stay exposed on SafeImage via
6
+ # `extend`; inline behavior lives in operation classes.
7
+ module Metadata
8
+ def probe(path, max_pixels: nil)
9
+ metadata_operations.probe(path, max_pixels: max_pixels)
10
+ end
11
+
12
+ def type(path, max_pixels: nil)
13
+ metadata_operations.type(path, max_pixels: max_pixels)
14
+ end
15
+
16
+ def size(path, max_pixels: nil)
17
+ metadata_operations.size(path, max_pixels: max_pixels)
18
+ end
19
+
20
+ def dimensions(path, max_pixels: nil)
21
+ metadata_operations.dimensions(path, max_pixels: max_pixels)
22
+ end
23
+
24
+ def info(path, max_pixels: nil, animated: false, orientation: false)
25
+ metadata_operations.info(path, max_pixels: max_pixels, animated: animated, orientation: orientation)
26
+ end
27
+
28
+ def orientation(path, max_pixels: nil)
29
+ metadata_operations.orientation(path, max_pixels: max_pixels)
30
+ end
31
+
32
+ def dominant_color(path, max_pixels: nil)
33
+ metadata_operations.dominant_color(path, max_pixels: max_pixels)
34
+ end
35
+
36
+ def remote_info(url, **kwargs)
37
+ config
38
+ Remote.info(url, **kwargs)
39
+ end
40
+
41
+ def remote_size(url, **kwargs)
42
+ config
43
+ Remote.size(url, **kwargs)
44
+ end
45
+
46
+ def remote_dimensions(url, **kwargs)
47
+ remote_size(url, **kwargs)
48
+ end
49
+
50
+ def remote_type(url, **kwargs)
51
+ config
52
+ Remote.type(url, **kwargs)
53
+ end
54
+
55
+ def remote_animated?(url, **kwargs)
56
+ config
57
+ Remote.animated?(url, **kwargs)
58
+ end
59
+
60
+ def remote_dominant_color(url, **kwargs)
61
+ config
62
+ Remote.dominant_color(url, **kwargs)
63
+ end
64
+
65
+ def fetch_remote(url, **kwargs, &block)
66
+ config
67
+ Remote.fetch(url, **kwargs, &block)
68
+ end
69
+
70
+ def frame_count(path, max_pixels: nil)
71
+ metadata_operations.frame_count(path, max_pixels: max_pixels)
72
+ end
73
+
74
+ def animated?(path, max_pixels: nil)
75
+ metadata_operations.animated?(path, max_pixels: max_pixels)
76
+ end
77
+
78
+ private
79
+
80
+ def metadata_operations
81
+ MetadataOperations.new(config: SafeImage.config)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeImage
4
+ module API
5
+ # Public image-producing operations. Every method requires explicit output
6
+ # paths; callers own any file replacement outside this API.
7
+ module Transform
8
+ def thumbnail(
9
+ input:,
10
+ output:,
11
+ width:,
12
+ height:,
13
+ format: nil,
14
+ quality: QualityDefaults::JPEG,
15
+ max_pixels: nil,
16
+ optimize: false,
17
+ optimize_mode: :lossless,
18
+ chroma_subsampling: :auto
19
+ )
20
+ transform_operations.thumbnail(
21
+ input: input,
22
+ output: output,
23
+ width: width,
24
+ height: height,
25
+ format: format,
26
+ quality: quality,
27
+ max_pixels: max_pixels,
28
+ optimize: optimize,
29
+ optimize_mode: optimize_mode,
30
+ chroma_subsampling: chroma_subsampling
31
+ )
32
+ end
33
+
34
+ def optimize(input:, output:, mode: :lossless, strip_metadata: true, quality: nil, strict: true)
35
+ transform_operations.optimize(
36
+ input: input,
37
+ output: output,
38
+ mode: mode,
39
+ strip_metadata: strip_metadata,
40
+ quality: quality,
41
+ strict: strict
42
+ )
43
+ end
44
+
45
+ def resize(
46
+ input:,
47
+ output:,
48
+ width:,
49
+ height:,
50
+ quality: nil,
51
+ optimize: true,
52
+ max_pixels: nil,
53
+ chroma_subsampling: :auto
54
+ )
55
+ transform_operations.resize(
56
+ input: input,
57
+ output: output,
58
+ width: width,
59
+ height: height,
60
+ quality: quality,
61
+ optimize: optimize,
62
+ max_pixels: max_pixels,
63
+ chroma_subsampling: chroma_subsampling
64
+ )
65
+ end
66
+
67
+ def crop(
68
+ input:,
69
+ output:,
70
+ width:,
71
+ height:,
72
+ quality: nil,
73
+ optimize: true,
74
+ max_pixels: nil,
75
+ chroma_subsampling: :auto
76
+ )
77
+ transform_operations.crop(
78
+ input: input,
79
+ output: output,
80
+ width: width,
81
+ height: height,
82
+ quality: quality,
83
+ optimize: optimize,
84
+ max_pixels: max_pixels,
85
+ chroma_subsampling: chroma_subsampling
86
+ )
87
+ end
88
+
89
+ def downsize(
90
+ input:,
91
+ output:,
92
+ dimensions:,
93
+ optimize: true,
94
+ max_pixels: nil,
95
+ quality: QualityDefaults::JPEG,
96
+ chroma_subsampling: :auto
97
+ )
98
+ transform_operations.downsize(
99
+ input: input,
100
+ output: output,
101
+ dimensions: dimensions,
102
+ optimize: optimize,
103
+ max_pixels: max_pixels,
104
+ quality: quality,
105
+ chroma_subsampling: chroma_subsampling
106
+ )
107
+ end
108
+
109
+ def convert(input:, output:, format:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
110
+ transform_operations.convert(
111
+ input: input,
112
+ output: output,
113
+ format: format,
114
+ quality: quality,
115
+ optimize: optimize,
116
+ max_pixels: max_pixels,
117
+ chroma_subsampling: chroma_subsampling
118
+ )
119
+ end
120
+
121
+ def fix_orientation(input:, output:, max_pixels: nil, quality: nil)
122
+ transform_operations.fix_orientation(input: input, output: output, max_pixels: max_pixels, quality: quality)
123
+ end
124
+
125
+ def convert_favicon_to_png(input:, output:, optimize: true, max_pixels: nil)
126
+ transform_operations.convert_favicon_to_png(
127
+ input: input,
128
+ output: output,
129
+ optimize: optimize,
130
+ max_pixels: max_pixels
131
+ )
132
+ end
133
+
134
+ def letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")
135
+ transform_operations.letter_avatar(
136
+ output: output,
137
+ size: size,
138
+ background_rgb: background_rgb,
139
+ letter: letter,
140
+ pointsize: pointsize,
141
+ font: font
142
+ )
143
+ end
144
+
145
+ private
146
+
147
+ def transform_operations
148
+ TransformOperations.new(config: SafeImage.config)
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeImage
4
+ module BackendLabel
5
+ module_function
6
+
7
+ BASE = {
8
+ vips: "libvips-helper",
9
+ imagemagick: "imagemagick",
10
+ vips_helper: "libvips-helper",
11
+ svg_metadata: "svg-metadata",
12
+ ico_metadata: "ico-metadata",
13
+ ico_vips: "ico-ruby+libvips-helper",
14
+ jpegtran: "jpegtran"
15
+ }.freeze
16
+
17
+ def build(backend, encoder: nil)
18
+ base = BASE.fetch(backend.to_sym)
19
+ encoder.to_s == "cjpegli" ? "#{base}+cjpegli" : base
20
+ end
21
+ end
22
+
23
+ private_constant :BackendLabel
24
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeImage
4
+ # Single source of truth for Ruby-side format normalization and allowlists.
5
+ # Native C helpers keep their own low-level allowlists, but Ruby orchestration
6
+ # should ask this module before building backend-specific commands.
7
+ module Formats
8
+ module_function
9
+
10
+ NATIVE_INPUTS = %w[jpg png gif webp heic heif avif jxl].freeze
11
+ NATIVE_OUTPUTS = %w[jpg png gif webp avif jxl].freeze
12
+ IMAGEMAGICK_INPUTS = (NATIVE_INPUTS + %w[ico]).freeze
13
+ IMAGEMAGICK_OUTPUTS = (NATIVE_OUTPUTS + %w[ico]).freeze
14
+ OPTIMIZABLE_OUTPUTS = %w[jpg png].freeze
15
+ CJPEGLI_DIRECT_INPUTS = %w[png].freeze
16
+
17
+ IMAGEMAGICK_DECODERS = {
18
+ "jpg" => "jpeg",
19
+ "png" => "png",
20
+ "gif" => "gif",
21
+ "webp" => "webp",
22
+ "heic" => "heic",
23
+ "heif" => "heic",
24
+ "avif" => "heic",
25
+ "ico" => "ico",
26
+ "jxl" => "jxl"
27
+ }.freeze
28
+
29
+ IMAGEMAGICK_OUTPUT_CODERS = {
30
+ "jpg" => "jpeg",
31
+ "png" => "png",
32
+ "gif" => "gif",
33
+ "webp" => "webp",
34
+ "avif" => "avif",
35
+ "ico" => "ico",
36
+ "jxl" => "jxl"
37
+ }.freeze
38
+
39
+ def normalize(format)
40
+ format = format.to_s.delete_prefix(".").downcase
41
+ format == "jpeg" ? "jpg" : format
42
+ end
43
+
44
+ # Canonical names used by libvips' explicit loaders/savers. HEIF-family
45
+ # inputs share the HEIC loader; AVIF remains distinct because saves need the
46
+ # AV1 compression option.
47
+ def native_canonical(format)
48
+ normalized = normalize(format)
49
+ normalized == "heif" ? "heic" : normalized
50
+ end
51
+
52
+ def extension(path)
53
+ normalize(File.extname(PathSafety.local_path(path)).delete_prefix("."))
54
+ end
55
+
56
+ def native_input?(format)
57
+ NATIVE_INPUTS.include?(normalize(format))
58
+ end
59
+
60
+ def native_output?(format)
61
+ NATIVE_OUTPUTS.include?(normalize(format))
62
+ end
63
+
64
+ def imagemagick_input?(format)
65
+ IMAGEMAGICK_INPUTS.include?(normalize(format))
66
+ end
67
+
68
+ def imagemagick_output?(format)
69
+ IMAGEMAGICK_OUTPUTS.include?(normalize(format))
70
+ end
71
+
72
+ def optimizable_output?(format)
73
+ OPTIMIZABLE_OUTPUTS.include?(normalize(format))
74
+ end
75
+
76
+ def cjpegli_direct_input?(format)
77
+ CJPEGLI_DIRECT_INPUTS.include?(normalize(format))
78
+ end
79
+
80
+ def imagemagick_decoder(format)
81
+ normalized = normalize(format)
82
+ IMAGEMAGICK_DECODERS.fetch(normalized) do
83
+ raise UnsupportedFormatError, "unsupported ImageMagick input format: #{normalized.inspect}"
84
+ end
85
+ end
86
+
87
+ def imagemagick_output_coder(format)
88
+ normalized = normalize(format)
89
+ IMAGEMAGICK_OUTPUT_CODERS.fetch(normalized) do
90
+ raise UnsupportedFormatError, "unsupported ImageMagick output format: #{normalized.inspect}"
91
+ end
92
+ end
93
+ end
94
+
95
+ private_constant :Formats
96
+ end
@@ -45,7 +45,7 @@ module SafeImage
45
45
  end
46
46
 
47
47
  # Extracts the largest icon and writes it as PNG. Returns an info hash in
48
- # the shape DiscourseCompat.result_from_info expects.
48
+ # the shape OperationBackends::Base#result_from_info expects.
49
49
  def convert_to_png(input, output, max_pixels: nil)
50
50
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
51
51
  data, entries = parse(input)
@@ -53,25 +53,27 @@ module SafeImage
53
53
  output = PathSafety.ensure_safe_output_path!(output).to_s
54
54
 
55
55
  width = height = nil
56
- if entry.png
57
- # Enforce the pixel cap from the IHDR dimensions before the payload
58
- # reaches a decoder.
59
- validate_pixels!(*entry_dimensions(data, entry), max_pixels)
60
- payload = data.byteslice(entry.offset, entry.size)
61
- Tempfile.create(["safe-image-ico", ".png"]) do |tmp|
62
- tmp.binmode
63
- tmp.write(payload)
64
- tmp.close
65
- # Sanitizing no-op resize: validates the PNG bytes, enforces the
66
- # pixel cap and strips metadata on the way through libvips.
67
- info = Native.resize(tmp.path, output, 1.0, "png", 100, max_pixels)
68
- width = info.fetch(:width)
69
- height = info.fetch(:height)
56
+ StagedOutput.replace(output, suffix: ".safe-image.png") do |tmp_path|
57
+ if entry.png
58
+ # Enforce the pixel cap from the IHDR dimensions before the payload
59
+ # reaches a decoder.
60
+ validate_pixels!(*entry_dimensions(data, entry), max_pixels)
61
+ payload = data.byteslice(entry.offset, entry.size)
62
+ Tempfile.create(%w[safe-image-ico .png]) do |tmp|
63
+ tmp.binmode
64
+ tmp.write(payload)
65
+ tmp.close
66
+ # Sanitizing no-op resize: validates the PNG bytes, enforces the
67
+ # pixel cap and strips metadata on the way through libvips.
68
+ info = Native.resize(tmp.path, tmp_path.to_s, 1.0, "png", 100, max_pixels)
69
+ width = info.fetch(:width)
70
+ height = info.fetch(:height)
71
+ end
72
+ else
73
+ rgba, width, height = decode_rgba(data, entry)
74
+ validate_pixels!(width, height, max_pixels)
75
+ Native.png_from_rgba(rgba, width, height, tmp_path.to_s)
70
76
  end
71
- else
72
- rgba, width, height = decode_rgba(data, entry)
73
- validate_pixels!(width, height, max_pixels)
74
- Native.png_from_rgba(rgba, width, height, output)
75
77
  end
76
78
 
77
79
  {
@@ -83,14 +85,6 @@ module SafeImage
83
85
  }
84
86
  end
85
87
 
86
- def dominant_color(path, max_pixels: nil)
87
- Tempfile.create(["safe-image-ico", ".png"]) do |tmp|
88
- tmp.close
89
- convert_to_png(path, tmp.path, max_pixels: max_pixels)
90
- VipsBackend.dominant_color(tmp.path, max_pixels: max_pixels)
91
- end
92
- end
93
-
94
88
  # -- container parsing ---------------------------------------------------
95
89
 
96
90
  def parse(path)
@@ -136,7 +130,7 @@ module SafeImage
136
130
  # PNG payloads carry their real dimensions in the IHDR chunk; the
137
131
  # one-byte directory fields saturate at 256.
138
132
  def entry_dimensions(data, entry)
139
- return [entry.width, entry.height] unless entry.png
133
+ return entry.width, entry.height unless entry.png
140
134
  raise InvalidImageError, "png payload is truncated" if entry.size < 24
141
135
  data.byteslice(entry.offset + 16, 8).unpack("NN")
142
136
  end
@@ -160,7 +154,7 @@ module SafeImage
160
154
  payload.unpack("Vl<l<vvVVl<l<V")
161
155
  raise InvalidImageError, "unsupported dib header (size #{header_size})" if header_size != 40
162
156
  raise InvalidImageError, "unsupported dib compression #{compression}" unless compression == BI_RGB
163
- raise InvalidImageError, "unsupported dib bit depth #{bpp}" unless [1, 4, 8, 24, 32].include?(bpp)
157
+ raise InvalidImageError, "unsupported dib bit depth #{bpp}" if [1, 4, 8, 24, 32].none? { |bits| bits == bpp }
164
158
 
165
159
  top_down = height2.negative?
166
160
  height = height2.abs / 2
@@ -174,9 +168,12 @@ module SafeImage
174
168
  palette_count = colors_used.zero? ? (1 << bpp) : colors_used
175
169
  raise InvalidImageError, "dib palette is invalid" if palette_count > 1 << bpp
176
170
  raise InvalidImageError, "dib palette is truncated" if payload.bytesize < palette_offset + palette_count * 4
177
- palette = payload.byteslice(palette_offset, palette_count * 4).unpack("C*").each_slice(4).map do |b, g, r, _x|
178
- [r, g, b]
179
- end
171
+ palette =
172
+ payload
173
+ .byteslice(palette_offset, palette_count * 4)
174
+ .unpack("C*")
175
+ .each_slice(4)
176
+ .map { |b, g, r, _x| [r, g, b] }
180
177
  palette_offset += palette_count * 4
181
178
  end
182
179
 
@@ -184,7 +181,9 @@ module SafeImage
184
181
  and_stride = ((width + 31) / 32) * 4
185
182
  xor_bytes = xor_stride * height
186
183
  and_bytes = and_stride * height
187
- raise InvalidImageError, "dib pixel data is truncated" if payload.bytesize < palette_offset + xor_bytes + and_bytes
184
+ if payload.bytesize < palette_offset + xor_bytes + and_bytes
185
+ raise InvalidImageError, "dib pixel data is truncated"
186
+ end
188
187
 
189
188
  xor_data = payload.byteslice(palette_offset, xor_bytes)
190
189
  and_data = payload.byteslice(palette_offset + xor_bytes, and_bytes)
@@ -205,9 +204,7 @@ module SafeImage
205
204
  # rotate-right-by-8.
206
205
  def decode_32bpp(xor_data, and_data, width, height, and_stride, top_down)
207
206
  stride = width * 4
208
- unless top_down
209
- xor_data = (0...height).map { |y| xor_data.byteslice((height - 1 - y) * stride, stride) }.join
210
- end
207
+ xor_data = (0...height).map { |y| xor_data.byteslice((height - 1 - y) * stride, stride) }.join unless top_down
211
208
  pixels = xor_data.unpack("N*")
212
209
 
213
210
  if alpha_all_zero?(xor_data)
@@ -261,10 +258,15 @@ module SafeImage
261
258
 
262
259
  index =
263
260
  case bpp
264
- when 8 then xor_row.getbyte(x)
265
- when 4 then (byte = xor_row.getbyte(x >> 1)
266
- x.even? ? byte >> 4 : byte & 0x0F)
267
- else (xor_row.getbyte(x >> 3) >> (7 - (x & 7))) & 1
261
+ when 8
262
+ xor_row.getbyte(x)
263
+ when 4
264
+ (
265
+ byte = xor_row.getbyte(x >> 1)
266
+ x.even? ? byte >> 4 : byte & 0x0F
267
+ )
268
+ else
269
+ (xor_row.getbyte(x >> 3) >> (7 - (x & 7))) & 1
268
270
  end
269
271
  rgba << (masked ? transparent : opaque).fetch(index) do
270
272
  raise InvalidImageError, "dib palette index #{index} is out of range"