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
@@ -37,10 +37,21 @@ module SafeImage
37
37
  reject_symlink_components!(path.dirname)
38
38
  if File.exist?(path.to_s)
39
39
  raise UnsafePathError, "output path is a symlink: #{path}" if File.lstat(path.to_s).symlink?
40
+ raise UnsafePathError, "output path is a directory: #{path}" if File.directory?(path.to_s)
40
41
  end
41
42
  path
42
43
  end
43
44
 
45
+ def ensure_distinct_file_paths!(input, output)
46
+ input = ensure_regular_file!(input)
47
+ output = ensure_safe_output_path!(output)
48
+ same_path = input.to_s == output.to_s
49
+ same_file = output.exist? && File.identical?(input.to_s, output.to_s)
50
+ raise UnsafePathError, "input and output must be different paths" if same_path || same_file
51
+
52
+ [input, output]
53
+ end
54
+
44
55
  def ensure_imagemagick_safe!(path)
45
56
  path = local_path(path)
46
57
  raise UnsafePathError, "path contains NUL" if path.include?("\0")
@@ -1,146 +1,183 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
- require "pathname"
5
- require "tempfile"
6
-
7
3
  module SafeImage
8
4
  class Processor
9
- SUPPORTED_INPUTS = %w[jpg jpeg png gif webp heic heif avif jxl].freeze
10
- SUPPORTED_OUTPUTS = %w[jpg jpeg png gif webp avif jxl].freeze
5
+ SUPPORTED_INPUTS = Formats::NATIVE_INPUTS
6
+ SUPPORTED_OUTPUTS = Formats::NATIVE_OUTPUTS
11
7
  # Formats the post-processing optimizer tools understand; other outputs
12
8
  # skip the optimize pass instead of erroring.
13
- OPTIMIZABLE_OUTPUTS = %w[jpg png].freeze
9
+ OPTIMIZABLE_OUTPUTS = Formats::OPTIMIZABLE_OUTPUTS
14
10
 
15
- def initialize(max_pixels: nil, chroma_subsampling: :auto)
16
- @max_pixels = max_pixels || SafeImage.config.max_pixels
11
+ def initialize(max_pixels: nil, chroma_subsampling: :auto, config: SafeImage.config)
12
+ @config = config
13
+ @max_pixels = max_pixels || config.max_pixels
17
14
  @chroma_subsampling = chroma_subsampling
18
15
  end
19
16
 
20
17
  def probe(path)
21
18
  input = safe_existing_file!(path)
22
- info = Native.probe(input.to_s)
19
+ info = Native.probe(input.to_s, @max_pixels)
23
20
  validate_pixels!(info.fetch(:width), info.fetch(:height))
24
- Result.new(
25
- input: input.to_s,
26
- output: nil,
21
+ Result.metadata(
22
+ input: input,
27
23
  input_format: info.fetch(:format),
28
- output_format: nil,
29
24
  width: info.fetch(:width),
30
25
  height: info.fetch(:height),
31
- filesize: File.size(input),
32
- backend: "libvips-direct",
33
- duration_ms: info.fetch(:duration_ms),
34
- optimizer: nil
26
+ backend: :vips,
27
+ duration_ms: info.fetch(:duration_ms)
35
28
  )
36
29
  end
37
30
 
38
- def thumbnail(input:, output:, width:, height:, format: nil, quality: 85, optimize: false, optimize_mode: :lossless)
39
- input = safe_existing_file!(input)
40
- output = safe_output_path!(output)
31
+ def thumbnail(
32
+ input:,
33
+ output:,
34
+ width:,
35
+ height:,
36
+ format: nil,
37
+ quality: QualityDefaults::JPEG,
38
+ optimize: false,
39
+ optimize_mode: :lossless
40
+ )
41
+ input, output, width, height, quality, out_format =
42
+ prepare_thumbnail_request(
43
+ input: input,
44
+ output: output,
45
+ width: width,
46
+ height: height,
47
+ format: format,
48
+ quality: quality
49
+ )
50
+ info =
51
+ thumbnail_info(input: input, output: output, width: width, height: height, quality: quality, format: out_format)
52
+ opt_info =
53
+ optimize_thumbnail_output(output, format: out_format, quality: quality, mode: optimize_mode) if optimize
54
+
55
+ thumbnail_result(input: input, output: output, info: info, optimizer: opt_info)
56
+ end
57
+
58
+ private
59
+
60
+ def prepare_thumbnail_request(input:, output:, width:, height:, format:, quality:)
61
+ input, output = PathSafety.ensure_distinct_file_paths!(input, output)
62
+ safe_existing_file!(input)
41
63
  width = Integer(width)
42
64
  height = Integer(height)
43
65
  quality = Integer(quality)
44
66
  raise ArgumentError, "width and height must be positive" if width <= 0 || height <= 0
45
67
  raise ArgumentError, "quality must be 1..100" unless (1..100).cover?(quality)
46
68
 
47
- out_format = (format || output.extname.delete_prefix(".")).downcase
48
- out_format = "jpg" if out_format == "jpeg"
49
- unless SUPPORTED_OUTPUTS.include?(out_format)
69
+ out_format = Formats.normalize(format || output.extname.delete_prefix("."))
70
+ unless Formats.native_output?(out_format)
50
71
  raise UnsupportedFormatError, "unsupported output format: #{out_format.inspect}"
51
72
  end
52
73
 
53
74
  output.dirname.mkpath
54
- backend = SafeImage.config.backend
55
- info =
56
- if out_format == "jpg" && use_jpegli_for_generated_jpeg?(backend)
57
- jpegli_thumbnail(input: input, output: output, width: width, height: height, quality: quality, source_format: input.extname.delete_prefix(".").downcase)
58
- else
59
- case backend
60
- when :vips
61
- Native.thumbnail(input.to_s, output.to_s, width, height, out_format, quality, @max_pixels)
62
- when :imagemagick
63
- probe_info = ImageMagickBackend.probe(input.to_s)
64
- validate_pixels!(probe_info.fetch(:width), probe_info.fetch(:height))
65
- ImageMagickBackend.thumbnail(
66
- input: input.to_s,
67
- output: output.to_s,
68
- width: width,
69
- height: height,
70
- format: out_format,
71
- quality: quality
72
- )
73
- end
74
- end
75
-
76
- opt_info = nil
77
- if optimize && OPTIMIZABLE_OUTPUTS.include?(out_format)
78
- opt_info = Optimizer.optimize(output, mode: optimize_mode, strip_metadata: true, quality: out_format == "jpg" ? quality : nil, assume_upright: true)
75
+ [input, output, width, height, quality, out_format]
76
+ end
77
+
78
+ def thumbnail_info(input:, output:, width:, height:, quality:, format:)
79
+ if jpegli_thumbnail?(format)
80
+ info =
81
+ jpegli_thumbnail(
82
+ input: input,
83
+ output: output,
84
+ width: width,
85
+ height: height,
86
+ quality: quality,
87
+ source_format: input.extname.delete_prefix(".").downcase
88
+ )
89
+ return info
90
+ end
91
+
92
+ case @config.backend
93
+ when :vips
94
+ Native.thumbnail(input.to_s, output.to_s, width, height, format, quality, @max_pixels)
95
+ when :imagemagick
96
+ imagemagick_thumbnail(
97
+ input: input,
98
+ output: output,
99
+ width: width,
100
+ height: height,
101
+ quality: quality,
102
+ format: format
103
+ )
79
104
  end
105
+ end
80
106
 
81
- Result.new(
107
+ def imagemagick_thumbnail(input:, output:, width:, height:, quality:, format:)
108
+ probe_info = ImageMagickBackend.probe(input.to_s)
109
+ validate_pixels!(probe_info.fetch(:width), probe_info.fetch(:height))
110
+ ImageMagickBackend.thumbnail(
82
111
  input: input.to_s,
83
112
  output: output.to_s,
113
+ width: width,
114
+ height: height,
115
+ format: format,
116
+ quality: quality
117
+ )
118
+ end
119
+
120
+ def optimize_thumbnail_output(output, format:, quality:, mode:)
121
+ if OPTIMIZABLE_OUTPUTS.include?(format)
122
+ optimize_output(
123
+ output,
124
+ mode: mode,
125
+ strip_metadata: true,
126
+ quality: format == "jpg" ? quality : nil,
127
+ assume_upright: true
128
+ )
129
+ end
130
+ end
131
+
132
+ def thumbnail_result(input:, output:, info:, optimizer:)
133
+ Result.build(
134
+ input: input,
135
+ output: output,
84
136
  input_format: info.fetch(:input_format),
85
137
  output_format: info.fetch(:output_format),
86
138
  width: info.fetch(:width),
87
139
  height: info.fetch(:height),
88
- filesize: File.size(output),
89
- backend: result_backend(info, backend),
140
+ backend: @config.backend,
141
+ encoder: info[:encoder],
90
142
  duration_ms: info.fetch(:duration_ms),
91
- optimizer: opt_info&.fetch(:tools, nil)
143
+ optimizer: optimizer&.fetch(:tools, nil),
144
+ tier: info[:encoder] == "cjpegli" ? :cjpegli : :thumbnail
92
145
  )
93
146
  end
94
147
 
95
- private
148
+ def optimize_output(output, **options)
149
+ StagedOutput.replace(output, suffix: ".safe-image#{output.extname}") do |tmp_path|
150
+ Optimizer.optimize(input: output, output: tmp_path, **options)
151
+ end
152
+ end
96
153
 
97
154
  # cjpegli is an output-quality tool, not a configuration choice: installed
98
155
  # means used. It encodes only pixels this gem already decoded, so it is
99
156
  # not part of the untrusted-input surface the backend choice controls.
100
- def use_jpegli_for_generated_jpeg?(backend)
101
- backend == :vips && JpegliBackend.available?
157
+ def jpegli_thumbnail?(format)
158
+ @config.backend == :vips && format == "jpg" && JpegliBackend.available?
102
159
  end
103
160
 
104
161
  def jpegli_thumbnail(input:, output:, width:, height:, quality:, source_format:)
105
- output.dirname.mkpath
106
- Tempfile.create([output.basename(".*").to_s, ".safe-image.png"], output.dirname.to_s) do |tmp|
107
- tmp_path = Pathname.new(tmp.path)
108
- tmp.close
109
- Native.thumbnail(input.to_s, tmp_path.to_s, width, height, "png", 100, @max_pixels)
110
- JpegliBackend.encode(
111
- input: tmp_path,
112
- output: output,
113
- quality: quality,
114
- chroma_subsampling: JpegliBackend.validate_chroma_subsampling!(@chroma_subsampling, input_format: normalized_source_format(source_format)),
115
- input_format: normalized_source_format(source_format)
116
- )
117
- ensure
118
- FileUtils.rm_f(tmp_path) if defined?(tmp_path) && tmp_path
119
- end
162
+ JpegliBackend.encode_generated_jpeg(
163
+ output: output,
164
+ quality: quality,
165
+ chroma_subsampling: @chroma_subsampling,
166
+ input_format: normalized_source_format(source_format)
167
+ ) { |tmp_path| Native.thumbnail(input.to_s, tmp_path.to_s, width, height, "png", 100, @max_pixels) }
120
168
  end
121
169
 
122
170
  def normalized_source_format(format)
123
- format = format.to_s.downcase
124
- format == "jpeg" ? "jpg" : format
125
- end
126
-
127
- def result_backend(info, backend)
128
- base = backend == :vips ? "libvips-direct" : "imagemagick"
129
- info[:encoder] == "cjpegli" ? "#{base}+cjpegli" : base
171
+ Formats.normalize(format)
130
172
  end
131
173
 
132
174
  def safe_existing_file!(path)
133
175
  path = PathSafety.ensure_regular_file!(path)
134
- ext = path.extname.delete_prefix(".").downcase
135
- ext = "jpg" if ext == "jpeg"
136
- raise UnsupportedFormatError, "unsupported input format: #{ext.inspect}" unless SUPPORTED_INPUTS.include?(ext)
176
+ ext = Formats.extension(path)
177
+ raise UnsupportedFormatError, "unsupported input format: #{ext.inspect}" unless Formats.native_input?(ext)
137
178
  path
138
179
  end
139
180
 
140
- def safe_output_path!(path)
141
- PathSafety.ensure_safe_output_path!(path)
142
- end
143
-
144
181
  def validate_pixels!(width, height)
145
182
  return unless @max_pixels
146
183
  pixels = Integer(width) * Integer(height)
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeImage
4
+ # Named quality defaults shared by the Ruby paths and mirrored by the native
5
+ # helper's DEFAULT_*_QUALITY macros. Keep these values documented in one place
6
+ # so new operations do not grow ad hoc JPEG quality choices.
7
+ module QualityDefaults
8
+ # Public transform default: historical safe_image behaviour and cjpegli's
9
+ # default quality for generated JPEGs.
10
+ JPEG = 85
11
+
12
+ # Native convert uses ImageMagick's usual default for inputs without embedded
13
+ # quality tables instead of libvips' lower Q75 default.
14
+ NATIVE_CONVERT_JPEG = 92
15
+
16
+ # fix_orientation only reaches this lossy tier when the lossless jpegtran
17
+ # path is unavailable or rejected; keep quality high to minimize generation
18
+ # loss while still validating a sane caller override.
19
+ FIX_ORIENTATION_REENCODE_JPEG = 95
20
+ end
21
+
22
+ private_constant :QualityDefaults
23
+ end