safe_image 0.2.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -10
  3. data/README.md +176 -168
  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 +43 -41
  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 -281
  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 +250 -48
  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 +367 -97
  28. data/lib/safe_image/result.rb +71 -23
  29. data/lib/safe_image/runner.rb +69 -24
  30. data/lib/safe_image/sandbox.rb +44 -191
  31. data/lib/safe_image/staged_output.rb +44 -0
  32. data/lib/safe_image/svg_metadata.rb +186 -55
  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 -294
  37. metadata +48 -26
  38. data/lib/safe_image/discourse_compat.rb +0 -452
  39. data/lib/safe_image/svg_sanitizer.rb +0 -102
  40. data/lib/safe_image/vips_glue.rb +0 -361
@@ -1,28 +1,76 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeImage
4
- Info = Data.define(
5
- :path,
6
- :type,
7
- :width,
8
- :height,
9
- :size,
10
- :animated,
11
- :orientation
12
- )
4
+ # Public metadata return value. Optional fields (`animated`, `orientation`) are
5
+ # nil unless the caller opts into the extra probes.
6
+ Info = Data.define(:path, :type, :width, :height, :size, :animated, :orientation)
13
7
 
14
- Result = Data.define(
15
- :input,
16
- :output,
17
- :input_format,
18
- :output_format,
19
- :width,
20
- :height,
21
- :filesize,
22
- :backend,
23
- :duration_ms,
24
- :optimizer
25
- ) do
26
- def success? = true
27
- end
8
+ # Public image-operation return value. `output`/`output_format` are nil for
9
+ # metadata-only probes; image-producing operations always include them. `tier`
10
+ # records the concrete execution tier (for example :native_encode,
11
+ # :jpegtran_lossless or :jpegtran_fallback_reencode) so non-fatal degradation
12
+ # is observable without parsing the backend label.
13
+ Result =
14
+ Data.define(
15
+ :input,
16
+ :output,
17
+ :input_format,
18
+ :output_format,
19
+ :width,
20
+ :height,
21
+ :filesize,
22
+ :backend,
23
+ :duration_ms,
24
+ :optimizer,
25
+ :tier
26
+ ) do
27
+ def self.build(
28
+ input:,
29
+ output: nil,
30
+ input_format:,
31
+ output_format: nil,
32
+ width:,
33
+ height:,
34
+ backend:,
35
+ duration_ms:,
36
+ filesize: nil,
37
+ optimizer: nil,
38
+ encoder: nil,
39
+ tier: nil
40
+ )
41
+ new(
42
+ input: input.to_s,
43
+ output: output&.to_s,
44
+ input_format: input_format,
45
+ output_format: output_format,
46
+ width: width,
47
+ height: height,
48
+ filesize: filesize || result_filesize(input, output),
49
+ backend: BackendLabel.build(backend, encoder: encoder),
50
+ duration_ms: duration_ms,
51
+ optimizer: optimizer,
52
+ tier: tier
53
+ )
54
+ end
55
+
56
+ def self.metadata(input:, input_format:, width:, height:, backend:, duration_ms:)
57
+ build(
58
+ input: input,
59
+ input_format: input_format,
60
+ width: width,
61
+ height: height,
62
+ filesize: File.size(input),
63
+ backend: backend,
64
+ duration_ms: duration_ms,
65
+ tier: :metadata
66
+ )
67
+ end
68
+
69
+ def self.result_filesize(input, output)
70
+ path = output || input
71
+ path ? File.size(path) : nil
72
+ end
73
+
74
+ def success? = true
75
+ end
28
76
  end
@@ -6,14 +6,26 @@ require "timeout"
6
6
 
7
7
  module SafeImage
8
8
  class CommandError < Error
9
- attr_reader :command, :status, :stdout, :stderr
10
-
11
- def initialize(message, command:, status: nil, stdout: "", stderr: "")
12
- super(message)
9
+ attr_reader :command, :status, :stdout, :stderr, :category, :operation
10
+
11
+ def initialize(
12
+ message,
13
+ command:,
14
+ status: nil,
15
+ stdout: "",
16
+ stderr: "",
17
+ category: :command,
18
+ operation: nil,
19
+ original_error_class: nil,
20
+ stderr_tail: nil
21
+ )
22
+ super(message, original_error_class: original_error_class, stderr_tail: stderr_tail)
13
23
  @command = command
14
24
  @status = status
15
25
  @stdout = stdout
16
26
  @stderr = stderr
27
+ @category = category&.to_sym
28
+ @operation = operation&.to_sym
17
29
  end
18
30
  end
19
31
 
@@ -22,13 +34,24 @@ module SafeImage
22
34
 
23
35
  DEFAULT_TIMEOUT = 20
24
36
  MAX_OUTPUT_BYTES = 512 * 1024
37
+ # Give well-behaved tools a short flush window after TERM before KILL keeps
38
+ # the timeout hard without leaking process groups.
39
+ TERMINATE_GRACE_SECONDS = 0.2
25
40
  TRUSTED_PATH = "/usr/bin:/bin:/usr/local/bin".freeze
26
41
  ALLOWED_ENV_KEYS = %w[LANG LC_ALL LC_CTYPE TZ].freeze
27
42
  IMAGEMAGICK_POLICY_PATH = File.expand_path("imagemagick_policy", __dir__)
28
43
  IMAGEMAGICK_POLICY_FILE = File.join(IMAGEMAGICK_POLICY_PATH, "policy.xml").freeze
29
44
  BASE_ENV = {
30
45
  "PATH" => TRUSTED_PATH,
31
- "VIPS_BLOCK_UNTRUSTED" => "1"
46
+ "VIPS_BLOCK_UNTRUSTED" => "1",
47
+ # Cap glibc's per-thread malloc arenas. Multithreaded tools (oxipng's
48
+ # rayon pool, ImageMagick's OpenMP) otherwise reserve an arena per thread
49
+ # — up to 8x64MB of *address space* per core — which, combined with the
50
+ # sandbox's RLIMIT_AS memory cap, spuriously fails the tool under
51
+ # concurrency even though real memory use is tiny. AS counts reservations,
52
+ # not RSS; bounding arenas is the standard mitigation and costs nothing
53
+ # for these compute-bound tools.
54
+ "MALLOC_ARENA_MAX" => "2"
32
55
  }.freeze
33
56
 
34
57
  def run!(argv, timeout: DEFAULT_TIMEOUT, env: {}, sandbox: false, read: [], write: [])
@@ -62,7 +85,13 @@ module SafeImage
62
85
  remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
63
86
  if remaining <= 0
64
87
  kill_process_group(wait_thr.pid)
65
- raise CommandError.new("command timed out after #{timeout}s", command: argv, stdout: stdout, stderr: stderr)
88
+ raise CommandError.new(
89
+ "command timed out after #{timeout}s",
90
+ command: argv,
91
+ stdout: stdout,
92
+ stderr: stderr,
93
+ category: :timeout
94
+ )
66
95
  end
67
96
 
68
97
  readable, = IO.select(streams.keys, nil, nil, remaining)
@@ -75,7 +104,13 @@ module SafeImage
75
104
  buffer << chunk
76
105
  if buffer.bytesize > MAX_OUTPUT_BYTES
77
106
  kill_process_group(wait_thr.pid)
78
- raise CommandError.new("command output exceeded #{MAX_OUTPUT_BYTES} bytes", command: argv, stdout: stdout, stderr: stderr)
107
+ raise CommandError.new(
108
+ "command output exceeded #{MAX_OUTPUT_BYTES} bytes",
109
+ command: argv,
110
+ stdout: stdout,
111
+ stderr: stderr,
112
+ category: :output_limit
113
+ )
79
114
  end
80
115
  rescue IO::WaitReadable
81
116
  next
@@ -95,7 +130,13 @@ module SafeImage
95
130
  remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
96
131
  if remaining <= 0
97
132
  kill_process_group(wait_thr.pid)
98
- raise CommandError.new("command timed out after #{timeout}s", command: argv, stdout: stdout, stderr: stderr)
133
+ raise CommandError.new(
134
+ "command timed out after #{timeout}s",
135
+ command: argv,
136
+ stdout: stdout,
137
+ stderr: stderr,
138
+ category: :timeout
139
+ )
99
140
  end
100
141
  status = wait_thr.join(remaining)&.value
101
142
  end
@@ -106,15 +147,16 @@ module SafeImage
106
147
  raise
107
148
  end
108
149
 
109
- return [stdout, stderr] if status&.success?
150
+ return stdout, stderr if status&.success?
110
151
 
111
152
  raise CommandError.new(
112
- "command failed: #{argv.first} exited #{status&.exitstatus}",
113
- command: argv,
114
- status: status&.exitstatus,
115
- stdout: stdout,
116
- stderr: stderr
117
- )
153
+ "command failed: #{argv.first} exited #{status&.exitstatus}",
154
+ command: argv,
155
+ status: status&.exitstatus,
156
+ stdout: stdout,
157
+ stderr: stderr,
158
+ category: :exit_status
159
+ )
118
160
  end
119
161
 
120
162
  def kill_process_group(pid)
@@ -122,17 +164,18 @@ module SafeImage
122
164
  rescue Errno::ESRCH, Errno::EPERM
123
165
  ensure
124
166
  begin
125
- sleep 0.2
167
+ sleep TERMINATE_GRACE_SECONDS
126
168
  Process.kill("KILL", -pid)
127
169
  rescue Errno::ESRCH, Errno::EPERM
128
170
  end
129
171
  end
130
172
 
131
173
  def command_env(tmpdir, env = {})
132
- allowed = env.each_with_object({}) do |(key, value), hash|
133
- key = key.to_s
134
- hash[key] = value.to_s if ALLOWED_ENV_KEYS.include?(key)
135
- end
174
+ allowed =
175
+ env.each_with_object({}) do |(key, value), hash|
176
+ key = key.to_s
177
+ hash[key] = value.to_s if ALLOWED_ENV_KEYS.include?(key)
178
+ end
136
179
 
137
180
  BASE_ENV.merge(
138
181
  "MAGICK_CONFIGURE_PATH" => IMAGEMAGICK_POLICY_PATH,
@@ -163,10 +206,12 @@ module SafeImage
163
206
  name = name.to_s
164
207
  return name if name.include?(File::SEPARATOR) && File.file?(name) && File.executable?(name)
165
208
 
166
- TRUSTED_PATH.split(File::PATH_SEPARATOR).each do |dir|
167
- path = File.join(dir, name)
168
- return path if File.file?(path) && File.executable?(path)
169
- end
209
+ TRUSTED_PATH
210
+ .split(File::PATH_SEPARATOR)
211
+ .each do |dir|
212
+ path = File.join(dir, name)
213
+ return path if File.file?(path) && File.executable?(path)
214
+ end
170
215
 
171
216
  nil
172
217
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
3
  require "rbconfig"
5
4
  require "tmpdir"
6
5
 
@@ -15,208 +14,67 @@ module SafeImage
15
14
  open_files: 256
16
15
  }.freeze
17
16
 
18
- OPERATIONS = %w[
19
- probe thumbnail type size dimensions info orientation dominant_color optimize resize crop downsize convert convert_to_jpeg fix_orientation
20
- convert_favicon_to_png frame_count animated? letter_avatar optimize_image!
21
- sanitize_svg!
22
- ].freeze
23
-
24
17
  def available?
25
18
  require "landlock"
26
- Landlock::SafeExec.supported?
19
+ landlock_supported?
27
20
  rescue LoadError
28
21
  false
29
22
  end
30
23
 
31
- def capture_command!(argv, read:, write:, timeout: Runner::DEFAULT_TIMEOUT, env: nil, rlimits: DEFAULT_RLIMITS)
32
- require "landlock"
33
- env ||= Runner.command_env(Dir.tmpdir)
34
-
35
- result = Landlock::SafeExec.capture!(
36
- *argv.map(&:to_s),
37
- read: existing_paths([*Landlock::SafeExec.default_read_paths, *runtime_read_paths, *read]),
38
- write: existing_paths(write),
39
- execute: existing_paths([*Landlock::SafeExec.default_execute_paths, File.dirname(RbConfig.ruby)]),
40
- env: env.merge("SAFE_IMAGE_SANDBOX_CHILD" => "1"),
41
- inherit_env: false,
42
- timeout: timeout,
43
- rlimits: rlimits,
44
- seccomp_deny_network: true,
45
- max_output_bytes: 512 * 1024,
46
- truncate_output: false
47
- )
48
- [result.stdout, result.stderr]
49
- rescue LoadError
50
- raise Error, "landlock sandbox requested but the landlock gem is unavailable"
51
- rescue Landlock::SafeExec::CommandError => e
52
- raise CommandError.new(
53
- "sandboxed command failed: #{failure_detail(e)}",
54
- command: argv,
55
- status: e.status&.exitstatus,
56
- stdout: e.stdout,
57
- stderr: e.stderr
58
- )
24
+ def landlock_supported?
25
+ Landlock.supported?
59
26
  end
60
27
 
61
- def public_call!(operation, args:, kwargs:)
62
- operation = operation.to_s
63
- raise ArgumentError, "unsupported sandbox operation: #{operation}" unless OPERATIONS.include?(operation)
64
- result = run_worker!(operation, { args: args, kwargs: kwargs })
65
- operation == "type" && result ? result.to_sym : result
28
+ def landlock_command_error
29
+ Landlock::CommandError
66
30
  end
67
31
 
68
- def run_worker!(operation, request)
69
- operation = operation.to_s
70
- raise ArgumentError, "unsupported sandbox operation: #{operation}" unless OPERATIONS.include?(operation)
71
-
72
- require "landlock"
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
- )
83
- code = <<~'RUBY'
84
- require "json"
85
- require "safe_image"
86
-
87
- def deep_symbolize(value)
88
- case value
89
- when Hash
90
- value.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
91
- when Array
92
- value.map { |v| deep_symbolize(v) }
93
- else
94
- value
95
- end
96
- end
97
-
98
- payload = JSON.parse(ARGV.fetch(0), symbolize_names: true)
99
- operation = payload.fetch(:operation).to_s
100
- allowed_operations = %w[
101
- probe thumbnail type size dimensions info orientation dominant_color optimize resize crop downsize convert convert_to_jpeg fix_orientation
102
- convert_favicon_to_png frame_count animated? letter_avatar optimize_image! sanitize_svg!
103
- ]
104
- raise ArgumentError, "unsupported sandbox operation: #{operation}" unless allowed_operations.include?(operation)
105
-
106
- request = payload.fetch(:request)
107
- args = request[:args] || []
108
- kwargs = deep_symbolize(request[:kwargs] || {})
32
+ def landlock_abi
33
+ Landlock.abi_version
34
+ end
109
35
 
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
- )
36
+ def default_read_paths
37
+ %w[/usr /lib /lib64 /etc /bin /sbin /opt].select { |path| File.exist?(path) }
38
+ end
116
39
 
117
- result = SafeImage.__send__(operation, *args, **kwargs)
40
+ def default_execute_paths
41
+ %w[/usr /lib /lib64 /bin /sbin /opt].select { |path| File.exist?(path) }
42
+ end
118
43
 
119
- if defined?(SafeImage::Result) && result.is_a?(SafeImage::Result)
120
- puts JSON.dump({ __type: "Result", data: result.to_h })
121
- elsif defined?(SafeImage::Info) && result.is_a?(SafeImage::Info)
122
- puts JSON.dump({ __type: "Info", data: result.to_h })
123
- else
124
- puts JSON.dump({ __type: "Value", data: result })
125
- end
126
- RUBY
44
+ def landlock_capture!(argv, **options)
45
+ Landlock.capture!(argv.map(&:to_s), allow_all_known: true, **options)
46
+ end
127
47
 
128
- paths = sandbox_paths(request, operation)
129
- Dir.mktmpdir("safe-image-worker-") do |tmpdir|
130
- worker_env = Runner.command_env(tmpdir).merge(
131
- "SAFE_IMAGE_SANDBOX_CHILD" => "1",
132
- "GEM_HOME" => ENV["GEM_HOME"].to_s,
133
- "GEM_PATH" => ENV["GEM_PATH"].to_s,
134
- "RUBYLIB" => $LOAD_PATH.select { |p| p && File.directory?(p) }.join(File::PATH_SEPARATOR)
135
- )
48
+ def capture_command!(argv, read:, write:, timeout: Runner::DEFAULT_TIMEOUT, env: nil, rlimits: DEFAULT_RLIMITS)
49
+ require "landlock"
50
+ env ||= Runner.command_env(Dir.tmpdir)
136
51
 
137
- stdout, = Landlock::SafeExec.capture!(
138
- RbConfig.ruby,
139
- "-I#{File.expand_path("../../", __dir__)}",
140
- "-rjson",
141
- "-e",
142
- code,
143
- payload,
144
- read: existing_paths([*Landlock::SafeExec.default_read_paths, *runtime_read_paths, *paths.fetch(:read), tmpdir]),
145
- write: existing_paths([*paths.fetch(:write), tmpdir]),
146
- execute: existing_paths([*Landlock::SafeExec.default_execute_paths, File.dirname(RbConfig.ruby)]),
147
- env: worker_env,
148
- inherit_env: false,
149
- timeout: Runner::DEFAULT_TIMEOUT,
150
- rlimits: DEFAULT_RLIMITS,
52
+ result =
53
+ landlock_capture!(
54
+ argv,
55
+ read: existing_paths([*default_read_paths, *runtime_read_paths, *read]),
56
+ write: existing_paths(write),
57
+ execute: existing_paths([*default_execute_paths, File.dirname(RbConfig.ruby)]),
58
+ env: env.merge("SAFE_IMAGE_SANDBOX_CHILD" => "1"),
59
+ unsetenv_others: true,
60
+ timeout: timeout,
61
+ rlimits: rlimits,
151
62
  seccomp_deny_network: true,
152
63
  max_output_bytes: 512 * 1024,
153
64
  truncate_output: false
154
65
  )
155
- response = JSON.parse(stdout, symbolize_names: true)
156
- if response[:__type] == "Result"
157
- data = response.fetch(:data)
158
- Result.new(**data)
159
- elsif response[:__type] == "Info"
160
- data = response.fetch(:data)
161
- Info.new(**data)
162
- else
163
- response[:data]
164
- end
165
- end
66
+ [result.stdout, result.stderr]
166
67
  rescue LoadError
167
68
  raise Error, "landlock sandbox requested but the landlock gem is unavailable"
168
- rescue Landlock::SafeExec::CommandError => e
69
+ rescue landlock_command_error => e
169
70
  raise CommandError.new(
170
- "sandboxed worker failed: #{failure_detail(e)}",
171
- command: [RbConfig.ruby, "-e", "..."],
172
- status: e.status&.exitstatus,
173
- stdout: e.stdout,
174
- stderr: e.stderr
175
- )
176
- end
177
-
178
- def sandbox_paths(request, operation)
179
- read = []
180
- write = []
181
-
182
- values = []
183
- values.concat(Array(request[:args]))
184
- values.concat(Array(request.dig(:kwargs)&.values))
185
- values.flatten.compact.each do |value|
186
- next unless value.is_a?(String)
187
- next if value.empty? || value.include?("\0")
188
-
189
- expanded = File.expand_path(value) rescue next
190
- if File.exist?(expanded)
191
- read << expanded
192
- elsif looks_like_path?(value)
193
- write << File.dirname(expanded)
194
- end
195
- end
196
-
197
- # Common keyword names for generated outputs. Include the containing dir
198
- # even when a stale file already exists, because operations may replace it.
199
- kwargs = request[:kwargs] || {}
200
- %i[output to path].each do |key|
201
- next unless kwargs[key].is_a?(String)
202
- write << File.dirname(File.expand_path(kwargs[key]))
203
- end
204
-
205
- # In-place mutators need write permission for an existing input path too.
206
- if %w[optimize optimize_image! sanitize_svg! fix_orientation].include?(operation.to_s)
207
- first = Array(request[:args]).first
208
- if first.is_a?(String) && File.exist?(first)
209
- expanded = File.expand_path(first)
210
- write << expanded
211
- write << File.dirname(expanded)
212
- end
213
- end
214
-
215
- { read: read.uniq, write: write.uniq }
216
- end
217
-
218
- def looks_like_path?(value)
219
- value.start_with?("/", "./", "../") || File.extname(value) != ""
71
+ "sandboxed command failed: #{failure_detail(e)}",
72
+ command: argv,
73
+ status: e.status&.exitstatus,
74
+ stdout: e.stdout,
75
+ stderr: e.stderr,
76
+ category: :sandbox_command
77
+ )
220
78
  end
221
79
 
222
80
  def runtime_read_paths
@@ -229,12 +87,11 @@ module SafeImage
229
87
  paths << RbConfig::CONFIG["vendorarchdir"]
230
88
  # An --enable-shared Ruby installed outside the default read roots
231
89
  # (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.
90
+ # libdir; helper/tool subprocesses need read access before they can start.
234
91
  paths << RbConfig::CONFIG["libdir"]
235
92
  paths << File.dirname(RbConfig.ruby)
236
93
  # Pango/fontconfig need the font directories and configs for the native
237
- # letter_avatar text rendering inside the worker.
94
+ # letter_avatar text rendering inside the helper.
238
95
  paths << "/etc/fonts"
239
96
  paths << "/usr/share/fonts"
240
97
  paths << "/usr/local/share/fonts"
@@ -246,17 +103,13 @@ module SafeImage
246
103
  paths.flatten.compact.map(&:to_s).reject(&:empty?).select { |path| File.exist?(path) }.uniq
247
104
  end
248
105
 
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.
106
+ # Sandbox failures often happen before the child can write structured output
107
+ # (e.g. a denied shared-library read kills it at dynamic-link time); include
108
+ # stderr so CI logs remain diagnosable.
252
109
  def failure_detail(error)
253
110
  detail = error.stderr.to_s.strip
254
111
  detail = "exit status #{error.status&.exitstatus.inspect}" if detail.empty?
255
112
  detail[0, 2000]
256
113
  end
257
-
258
- def symbolize(hash)
259
- hash.transform_keys(&:to_sym)
260
- end
261
114
  end
262
115
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+ require "tempfile"
6
+
7
+ module SafeImage
8
+ # Helpers for same-directory temporary outputs. External tools often require a
9
+ # path, not an fd, so the helper intentionally creates and closes the tempfile
10
+ # before yielding its path. The temp path is always next to the destination so
11
+ # the final move stays on the same filesystem.
12
+ module StagedOutput
13
+ module_function
14
+
15
+ def replace(output, suffix: nil)
16
+ output_path = PathSafety.ensure_safe_output_path!(output)
17
+ output_path.dirname.mkpath
18
+ with_temp_path(output_path, suffix: suffix || output_path.extname) do |tmp_path|
19
+ result = yield tmp_path, output_path
20
+ FileUtils.mv(tmp_path, output_path)
21
+ result
22
+ end
23
+ end
24
+
25
+ def with_temp_path_near(output, suffix:)
26
+ output_path = PathSafety.ensure_safe_output_path!(output)
27
+ output_path.dirname.mkpath
28
+ with_temp_path(output_path, suffix: suffix) { |tmp_path| yield tmp_path, output_path }
29
+ end
30
+
31
+ def with_temp_path(output_path, suffix:)
32
+ Tempfile.create([output_path.basename(".*").to_s, suffix], output_path.dirname.to_s) do |tmp|
33
+ tmp_path = Pathname.new(tmp.path)
34
+ tmp.close
35
+ yield tmp_path
36
+ ensure
37
+ FileUtils.rm_f(tmp_path) if defined?(tmp_path) && tmp_path
38
+ end
39
+ end
40
+ private_class_method :with_temp_path
41
+ end
42
+
43
+ private_constant :StagedOutput
44
+ end