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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -12
- data/README.md +140 -287
- data/SECURITY.md +22 -11
- data/docs/architecture.md +63 -0
- data/ext/safe_image_vips_helper/extconf.rb +43 -0
- data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
- data/lib/safe_image/api/metadata.rb +85 -0
- data/lib/safe_image/api/transform.rb +152 -0
- data/lib/safe_image/backend_label.rb +24 -0
- data/lib/safe_image/formats.rb +96 -0
- data/lib/safe_image/ico.rb +42 -40
- data/lib/safe_image/image_magick_backend.rb +219 -162
- data/lib/safe_image/jpegli_backend.rb +64 -44
- data/lib/safe_image/metadata_operations.rb +155 -0
- data/lib/safe_image/native.rb +96 -290
- data/lib/safe_image/native_helper.rb +281 -0
- data/lib/safe_image/operation_backends/base.rb +83 -0
- data/lib/safe_image/operation_backends/image_magick.rb +123 -0
- data/lib/safe_image/operation_backends/vips.rb +251 -0
- data/lib/safe_image/operation_backends.rb +27 -0
- data/lib/safe_image/operation_set.rb +22 -0
- data/lib/safe_image/optimizer.rb +225 -98
- data/lib/safe_image/path_safety.rb +11 -0
- data/lib/safe_image/processor.rb +122 -85
- data/lib/safe_image/quality_defaults.rb +23 -0
- data/lib/safe_image/remote.rb +248 -144
- data/lib/safe_image/result.rb +71 -23
- data/lib/safe_image/runner.rb +60 -23
- data/lib/safe_image/sandbox.rb +44 -218
- data/lib/safe_image/staged_output.rb +44 -0
- data/lib/safe_image/svg_metadata.rb +74 -69
- data/lib/safe_image/transform_operations.rb +139 -0
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/vips_backend.rb +13 -12
- data/lib/safe_image.rb +62 -306
- metadata +43 -37
- data/lib/safe_image/discourse_compat.rb +0 -441
- data/lib/safe_image/svg_css.rb +0 -314
- data/lib/safe_image/svg_sanitizer.rb +0 -583
- data/lib/safe_image/vips_glue.rb +0 -361
- 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")
|
data/lib/safe_image/processor.rb
CHANGED
|
@@ -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 =
|
|
10
|
-
SUPPORTED_OUTPUTS =
|
|
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 =
|
|
9
|
+
OPTIMIZABLE_OUTPUTS = Formats::OPTIMIZABLE_OUTPUTS
|
|
14
10
|
|
|
15
|
-
def initialize(max_pixels: nil, chroma_subsampling: :auto)
|
|
16
|
-
@
|
|
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.
|
|
25
|
-
input: input
|
|
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
|
-
|
|
32
|
-
|
|
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(
|
|
39
|
-
input
|
|
40
|
-
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("."))
|
|
48
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
140
|
+
backend: @config.backend,
|
|
141
|
+
encoder: info[:encoder],
|
|
90
142
|
duration_ms: info.fetch(:duration_ms),
|
|
91
|
-
optimizer:
|
|
143
|
+
optimizer: optimizer&.fetch(:tools, nil),
|
|
144
|
+
tier: info[:encoder] == "cjpegli" ? :cjpegli : :thumbnail
|
|
92
145
|
)
|
|
93
146
|
end
|
|
94
147
|
|
|
95
|
-
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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 =
|
|
135
|
-
|
|
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
|