safe_image 0.2.0 → 0.3.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.
@@ -8,7 +8,20 @@ module SafeImage
8
8
 
9
9
  MAX_PNGQUANT_SIZE = 500_000
10
10
 
11
- def optimize(path, mode: :lossless, strip_metadata: true, quality: nil, timeout: Runner::DEFAULT_TIMEOUT, strict: true)
11
+ # EXIF orientation values mapped onto jpegtran's lossless transforms.
12
+ JPEGTRAN_OPERATIONS = {
13
+ 2 => ["-flip", "horizontal"],
14
+ 3 => ["-rotate", "180"],
15
+ 4 => ["-flip", "vertical"],
16
+ 5 => ["-transpose"],
17
+ 6 => ["-rotate", "90"],
18
+ 7 => ["-transverse"],
19
+ 8 => ["-rotate", "270"]
20
+ }.freeze
21
+
22
+ # assume_upright: skips the JPEG orientation check; only for callers
23
+ # optimising output this gem just encoded (which is always upright).
24
+ def optimize(path, mode: :lossless, strip_metadata: true, quality: nil, timeout: Runner::DEFAULT_TIMEOUT, strict: true, assume_upright: false)
12
25
  path = PathSafety.ensure_regular_file!(path)
13
26
 
14
27
  ext = path.extname.delete_prefix(".").downcase
@@ -16,9 +29,26 @@ module SafeImage
16
29
 
17
30
  before = File.size(path)
18
31
  tools = []
32
+ rotated_from = nil
33
+ trimmed = false
19
34
 
20
35
  case ext
21
36
  when "jpg"
37
+ # Stripping metadata deletes the EXIF orientation tag, so an oriented
38
+ # image must have the rotation baked into its pixels first or it ships
39
+ # sideways. jpegtran does that losslessly; without it, leave the file
40
+ # untouched rather than strip-without-rotate.
41
+ orientation = strip_metadata && !assume_upright ? jpeg_orientation(path) : 1
42
+ if orientation > 1
43
+ unless Runner.available?("jpegtran")
44
+ raise Error, "jpegtran is required to optimize a JPEG with EXIF orientation" if strict
45
+ return { format: ext, before_bytes: before, after_bytes: before, saved_bytes: 0, tools: tools, rotated_from: nil, trimmed: false }
46
+ end
47
+ trimmed = upright!(path, orientation, timeout: timeout)
48
+ rotated_from = orientation
49
+ tools << "jpegtran"
50
+ end
51
+
22
52
  if Runner.available?("jpegoptim")
23
53
  argv = ["jpegoptim", "--quiet"]
24
54
  argv << (strip_metadata ? "--strip-all" : "--strip-none")
@@ -39,8 +69,18 @@ module SafeImage
39
69
  argv = ["pngquant", "--force", "--skip-if-larger", "--output", tmp_path.to_s]
40
70
  argv << "--quality=#{quality}" if quality # e.g. "65-90"
41
71
  argv << path.to_s
42
- Runner.run!(argv, timeout: timeout)
43
- if tmp_path.file? && File.size(tmp_path) < File.size(path)
72
+ skipped = false
73
+ begin
74
+ Runner.run!(argv, timeout: timeout)
75
+ rescue CommandError => e
76
+ # 98: --skip-if-larger declined the result; 99: --quality not
77
+ # met. Both mean "keep the original", not a failure — and the
78
+ # pre-created tempfile is still empty, so it must not win the
79
+ # size comparison below.
80
+ raise unless [98, 99].include?(e.status)
81
+ skipped = true
82
+ end
83
+ if !skipped && tmp_path.file? && File.size(tmp_path).positive? && File.size(tmp_path) < File.size(path)
44
84
  FileUtils.mv(tmp_path, path)
45
85
  tools << "pngquant"
46
86
  end
@@ -71,8 +111,43 @@ module SafeImage
71
111
  before_bytes: before,
72
112
  after_bytes: after,
73
113
  saved_bytes: before - after,
74
- tools: tools
114
+ tools: tools,
115
+ rotated_from: rotated_from,
116
+ trimmed: trimmed
75
117
  }
76
118
  end
119
+
120
+ def jpeg_orientation(path)
121
+ case SafeImage.config.backend
122
+ when :vips then VipsBackend.orientation(path.to_s)
123
+ when :imagemagick then ImageMagickBackend.orientation(path.to_s)
124
+ end
125
+ end
126
+
127
+ # Applies the orientation's lossless jpegtran transform in place, dropping
128
+ # the metadata in the same pass (-copy none; this path only runs when
129
+ # strip_metadata is set). -perfect refuses dimensions that are not
130
+ # MCU-aligned; the -trim retry drops the partial edge blocks (under one
131
+ # MCU, at most 15px) instead of hiding a lossy re-encode here. Returns
132
+ # true when the fallback trimmed.
133
+ def upright!(path, orientation, timeout:)
134
+ transform = JPEGTRAN_OPERATIONS.fetch(orientation)
135
+ tmp = Tempfile.new([path.basename(".*").to_s, ".jpegtran.jpg"], path.dirname.to_s)
136
+ tmp_path = Pathname.new(tmp.path)
137
+ tmp.close
138
+ begin
139
+ trimmed = false
140
+ begin
141
+ Runner.run!(["jpegtran", "-copy", "none", "-perfect", *transform, "-outfile", tmp_path.to_s, path.to_s], timeout: timeout)
142
+ rescue CommandError
143
+ Runner.run!(["jpegtran", "-copy", "none", "-trim", *transform, "-outfile", tmp_path.to_s, path.to_s], timeout: timeout)
144
+ trimmed = true
145
+ end
146
+ FileUtils.mv(tmp_path, path)
147
+ trimmed
148
+ ensure
149
+ FileUtils.rm_f(tmp_path)
150
+ end
151
+ end
77
152
  end
78
153
  end
@@ -75,7 +75,7 @@ module SafeImage
75
75
 
76
76
  opt_info = nil
77
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)
78
+ opt_info = Optimizer.optimize(output, mode: optimize_mode, strip_metadata: true, quality: out_format == "jpg" ? quality : nil, assume_upright: true)
79
79
  end
80
80
 
81
81
  Result.new(
@@ -48,6 +48,45 @@ module SafeImage
48
48
 
49
49
  EXTENSIONS = %w[.jpg .jpeg .png .gif .webp .heic .heif .avif .ico .jxl .svg].freeze
50
50
 
51
+ # First-bytes signatures per downloaded extension, checked as soon as the
52
+ # first SIGNATURE_HEAD_BYTES of the body arrive so an obviously mislabeled
53
+ # response is dropped without downloading the rest. Each entry lists
54
+ # alternative candidates; a candidate is a list of [offset, bytes] pairs
55
+ # that must all match. The check rejects only on a definite mismatch of
56
+ # every candidate against fully-available bytes, so it can never reject an
57
+ # image the configured backend could decode — decoders sniff these same
58
+ # magic bytes to pick a loader. SVG has no usable signature and is exempt.
59
+ SIGNATURES = {
60
+ ".jpg" => [[[0, "\xFF\xD8\xFF".b]]],
61
+ ".jpeg" => [[[0, "\xFF\xD8\xFF".b]]],
62
+ ".png" => [[[0, "\x89PNG\r\n\x1A\n".b]]],
63
+ ".gif" => [[[0, "GIF8".b]]],
64
+ ".webp" => [[[0, "RIFF".b], [8, "WEBP".b]]],
65
+ ".ico" => [[[0, "\x00\x00\x01\x00".b]]],
66
+ ".heic" => [[[4, "ftyp".b]]],
67
+ ".heif" => [[[4, "ftyp".b]]],
68
+ ".avif" => [[[4, "ftyp".b]]],
69
+ ".jxl" => [[[0, "\xFF\x0A".b]], [[0, "\x00\x00\x00\x0CJXL \r\n\x87\n".b]]]
70
+ }.freeze
71
+
72
+ SIGNATURE_HEAD_BYTES = 12
73
+
74
+ # The metadata helpers probe the partially-downloaded file at these
75
+ # growing byte thresholds and abort the transfer once the answer is
76
+ # stable, instead of always downloading up to max_bytes.
77
+ PREFIX_PROBE_INITIAL_BYTES = 64 * 1024
78
+ PREFIX_PROBE_GROWTH_FACTOR = 4
79
+
80
+ # SVG is excluded from prefix probing: SvgMetadata enforces a total-size
81
+ # cap (MAX_SVG_BYTES) that probing a prefix would bypass, and remote SVGs
82
+ # are small enough that downloading them fully costs little.
83
+ PREFIX_PROBE_EXTENSIONS = (EXTENSIONS - [".svg"]).freeze
84
+
85
+ # Sentinel a metadata_fetch block returns when the prefix parsed but its
86
+ # answer could still change with more data (e.g. "not animated", which a
87
+ # truncated file can report for an animated one).
88
+ CONTINUE_DOWNLOAD = Object.new.freeze
89
+
51
90
  BLOCKED_IP_RANGES = [
52
91
  # IPv4 special-use / non-public ranges. Default remote fetching is for
53
92
  # public Internet images only; callers probing trusted internal URLs must
@@ -117,7 +156,7 @@ module SafeImage
117
156
  )
118
157
  file.flush
119
158
 
120
- ext = extension_for(response.fetch(:uri), response.fetch(:content_type))
159
+ ext = response.fetch(:ext)
121
160
  path = file.path
122
161
  if File.extname(path) != ext
123
162
  renamed = path.sub(/\.bin\z/, ext)
@@ -136,8 +175,18 @@ module SafeImage
136
175
  end
137
176
 
138
177
  def info(url, max_bytes: DEFAULT_MAX_BYTES, max_redirects: DEFAULT_MAX_REDIRECTS, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, total_timeout: DEFAULT_TOTAL_TIMEOUT, allow_private: false, allowed_ports: DEFAULT_ALLOWED_PORTS, headers: {}, max_pixels: nil, animated: false, orientation: false)
139
- fetch(url, max_bytes: max_bytes, max_redirects: max_redirects, open_timeout: open_timeout, read_timeout: read_timeout, total_timeout: total_timeout, allow_private: allow_private, allowed_ports: allowed_ports, headers: headers) do |path|
140
- SafeImage.info(path, max_pixels: max_pixels, animated: animated, orientation: orientation)
178
+ metadata_fetch(url, max_bytes: max_bytes, max_redirects: max_redirects, open_timeout: open_timeout, read_timeout: read_timeout, total_timeout: total_timeout, allow_private: allow_private, allowed_ports: allowed_ports, headers: headers) do |path, eof|
179
+ result = SafeImage.info(path, max_pixels: max_pixels, animated: animated, orientation: orientation)
180
+ # A truncated file can undercount frames but never overcount, so
181
+ # "animated" is final as soon as it is true; "not animated" is only
182
+ # provable from the complete file. Type, dimensions and orientation
183
+ # come from the header the successful probe just parsed, so they
184
+ # cannot change with more data.
185
+ if animated && result.animated != true && !eof
186
+ CONTINUE_DOWNLOAD
187
+ else
188
+ result
189
+ end
141
190
  end
142
191
  end
143
192
 
@@ -150,8 +199,11 @@ module SafeImage
150
199
  end
151
200
 
152
201
  def animated?(url, max_bytes: DEFAULT_MAX_BYTES, max_redirects: DEFAULT_MAX_REDIRECTS, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, total_timeout: DEFAULT_TOTAL_TIMEOUT, allow_private: false, allowed_ports: DEFAULT_ALLOWED_PORTS, headers: {}, max_pixels: nil)
153
- fetch(url, max_bytes: max_bytes, max_redirects: max_redirects, open_timeout: open_timeout, read_timeout: read_timeout, total_timeout: total_timeout, allow_private: allow_private, allowed_ports: allowed_ports, headers: headers) do |path|
154
- SafeImage.animated?(path, max_pixels: max_pixels)
202
+ metadata_fetch(url, max_bytes: max_bytes, max_redirects: max_redirects, open_timeout: open_timeout, read_timeout: read_timeout, total_timeout: total_timeout, allow_private: allow_private, allowed_ports: allowed_ports, headers: headers) do |path, eof|
203
+ answer = SafeImage.animated?(path, max_pixels: max_pixels)
204
+ next CONTINUE_DOWNLOAD if !eof && answer != true
205
+
206
+ answer
155
207
  end
156
208
  end
157
209
 
@@ -161,7 +213,82 @@ module SafeImage
161
213
  end
162
214
  end
163
215
 
164
- def request(uri, io:, max_bytes:, max_redirects:, open_timeout:, read_timeout:, total_timeout:, started_at:, allow_private:, allowed_ports:, headers: {})
216
+ # Single-GET download that re-attempts the local metadata probe as bytes
217
+ # arrive (at PREFIX_PROBE_INITIAL_BYTES, then growing by
218
+ # PREFIX_PROBE_GROWTH_FACTOR) and aborts the transfer as soon as the
219
+ # block's answer is final. The block receives (path, eof) and must return
220
+ # CONTINUE_DOWNLOAD while its answer could still change with more data.
221
+ #
222
+ # Safety contract: any error from a pre-EOF probe means "not enough bytes
223
+ # yet" — it is swallowed and the download continues, so the complete file
224
+ # always gets the last word with exactly the validation and error
225
+ # behaviour of the full-download path (validate_downloaded_image! plus an
226
+ # un-rescued final probe). A file that never early-exits is handled
227
+ # byte-for-byte like Remote.fetch handles it.
228
+ def metadata_fetch(url, max_bytes:, max_redirects:, open_timeout:, read_timeout:, total_timeout:, allow_private:, allowed_ports:, headers:, &compute)
229
+ uri = parse_uri(url)
230
+ started_at = monotonic_time
231
+
232
+ Tempfile.create(["safe-image-remote", ".bin"], binmode: true) do |file|
233
+ original_path = file.path
234
+ path = original_path
235
+ ext = nil
236
+ next_probe_at = PREFIX_PROBE_INITIAL_BYTES
237
+
238
+ begin
239
+ early = catch(:metadata_answer) do
240
+ request(
241
+ uri,
242
+ io: file,
243
+ max_bytes: max_bytes,
244
+ max_redirects: max_redirects,
245
+ open_timeout: open_timeout,
246
+ read_timeout: read_timeout,
247
+ total_timeout: total_timeout,
248
+ started_at: started_at,
249
+ allow_private: allow_private,
250
+ allowed_ports: allowed_ports,
251
+ headers: headers,
252
+ # The extension is known before the body: give the tempfile its
253
+ # final name up front so probes dispatch on the right loader.
254
+ on_headers: ->(response_ext) do
255
+ ext = response_ext
256
+ renamed = original_path.sub(/\.bin\z/, ext)
257
+ FileUtils.mv(original_path, renamed)
258
+ path = renamed
259
+ end,
260
+ on_progress: ->(bytes) do
261
+ next unless PREFIX_PROBE_EXTENSIONS.include?(ext)
262
+ next if bytes < next_probe_at
263
+
264
+ next_probe_at *= PREFIX_PROBE_GROWTH_FACTOR while bytes >= next_probe_at
265
+ file.flush
266
+ answer =
267
+ begin
268
+ compute.call(path, false)
269
+ rescue StandardError
270
+ CONTINUE_DOWNLOAD
271
+ end
272
+ throw :metadata_answer, [answer] unless CONTINUE_DOWNLOAD.equal?(answer)
273
+ end
274
+ )
275
+ nil
276
+ end
277
+
278
+ if early
279
+ early.first
280
+ else
281
+ file.flush
282
+ validate_downloaded_image!(path, ext)
283
+ compute.call(path, true)
284
+ end
285
+ ensure
286
+ FileUtils.rm_f(path) unless path == original_path
287
+ end
288
+ end
289
+ end
290
+
291
+ def request(uri, io:, max_bytes:, max_redirects:, open_timeout:, read_timeout:, total_timeout:, started_at:, allow_private:, allowed_ports:, headers: {}, on_headers: nil, on_progress: nil)
165
292
  require "net/http"
166
293
  raise ArgumentError, "too many redirects" if max_redirects < 0
167
294
  check_deadline!(started_at, total_timeout)
@@ -181,6 +308,7 @@ module SafeImage
181
308
 
182
309
  bytes = 0
183
310
  content_type = nil
311
+ ext = nil
184
312
 
185
313
  http.request(request) do |response|
186
314
  check_deadline!(started_at, total_timeout)
@@ -203,25 +331,63 @@ module SafeImage
203
331
  started_at: started_at,
204
332
  allow_private: allow_private,
205
333
  allowed_ports: allowed_ports,
206
- headers: redirect_headers(headers, from: uri, to: redirected)
334
+ headers: redirect_headers(headers, from: uri, to: redirected),
335
+ on_headers: on_headers,
336
+ on_progress: on_progress
207
337
  )
208
338
  when Net::HTTPSuccess
209
339
  content_length = response["content-length"].to_i
210
340
  raise LimitError, "remote image exceeds #{max_bytes} bytes" if content_length > max_bytes
211
341
 
212
342
  content_type = response["content-type"].to_s.split(";", 2).first.to_s.downcase
343
+ # Everything the content-type and extension-agreement checks need is
344
+ # in the headers: reject unsupported or mismatched responses before
345
+ # reading a single body byte.
346
+ ext = extension_for(uri, content_type)
347
+ on_headers&.call(ext)
348
+
349
+ head = "".b
350
+ head_checked = false
213
351
  response.read_body do |chunk|
214
352
  check_deadline!(started_at, total_timeout)
215
353
  bytes += chunk.bytesize
216
354
  raise LimitError, "remote image exceeds #{max_bytes} bytes" if bytes > max_bytes
355
+ unless head_checked
356
+ head << chunk
357
+ if head.bytesize >= SIGNATURE_HEAD_BYTES
358
+ verify_signature!(ext, head)
359
+ head_checked = true
360
+ head = nil
361
+ end
362
+ end
217
363
  io.write(chunk)
364
+ on_progress&.call(bytes)
218
365
  end
366
+ verify_signature!(ext, head) if !head_checked && !head.empty?
219
367
  else
220
368
  raise Error, "remote image request failed: HTTP #{response.code}"
221
369
  end
222
370
  end
223
371
 
224
- { uri: uri, content_type: content_type, bytes: bytes }
372
+ { uri: uri, content_type: content_type, ext: ext, bytes: bytes }
373
+ end
374
+
375
+ # Rejects a body whose first bytes definitively cannot belong to the
376
+ # format the response claimed. Formats without a fixed signature (SVG)
377
+ # and bytes not yet downloaded are never grounds for rejection.
378
+ def verify_signature!(ext, head)
379
+ candidates = SIGNATURES[ext]
380
+ return unless candidates
381
+
382
+ compatible = candidates.any? do |candidate|
383
+ candidate.all? do |offset, bytes|
384
+ slice = head.byteslice(offset, bytes.bytesize).to_s
385
+ slice.empty? || slice == bytes.byteslice(0, slice.bytesize)
386
+ end
387
+ end
388
+ return if compatible
389
+
390
+ raise InvalidImageError, "remote image first bytes do not match #{ext.delete_prefix(".")} signature"
225
391
  end
226
392
 
227
393
  def parse_uri(url)
@@ -28,7 +28,15 @@ module SafeImage
28
28
  IMAGEMAGICK_POLICY_FILE = File.join(IMAGEMAGICK_POLICY_PATH, "policy.xml").freeze
29
29
  BASE_ENV = {
30
30
  "PATH" => TRUSTED_PATH,
31
- "VIPS_BLOCK_UNTRUSTED" => "1"
31
+ "VIPS_BLOCK_UNTRUSTED" => "1",
32
+ # Cap glibc's per-thread malloc arenas. Multithreaded tools (oxipng's
33
+ # rayon pool, ImageMagick's OpenMP) otherwise reserve an arena per thread
34
+ # — up to 8x64MB of *address space* per core — which, combined with the
35
+ # sandbox's RLIMIT_AS memory cap, spuriously fails the tool under
36
+ # concurrency even though real memory use is tiny. AS counts reservations,
37
+ # not RSS; bounding arenas is the standard mitigation and costs nothing
38
+ # for these compute-bound tools.
39
+ "MALLOC_ARENA_MAX" => "2"
32
40
  }.freeze
33
41
 
34
42
  def run!(argv, timeout: DEFAULT_TIMEOUT, env: {}, sandbox: false, read: [], write: [])
@@ -61,7 +61,13 @@ module SafeImage
61
61
  def public_call!(operation, args:, kwargs:)
62
62
  operation = operation.to_s
63
63
  raise ArgumentError, "unsupported sandbox operation: #{operation}" unless OPERATIONS.include?(operation)
64
- result = run_worker!(operation, { args: args, kwargs: kwargs })
64
+ request = { args: args, kwargs: kwargs }
65
+ result =
66
+ if Zygote.enabled?
67
+ Zygote.call!(operation, request)
68
+ else
69
+ run_worker!(operation, request)
70
+ end
65
71
  operation == "type" && result ? result.to_sym : result
66
72
  end
67
73
 
@@ -74,7 +80,10 @@ module SafeImage
74
80
  payload = JSON.dump(
75
81
  {
76
82
  operation: operation,
77
- request: request,
83
+ # JSON has no symbol type; wrap symbol values so the worker can restore
84
+ # them (e.g. id_namespace: :standalone must not arrive as the string
85
+ # "standalone", which resolve_namespace would treat as a real namespace).
86
+ request: deep_encode_symbols(request),
78
87
  # The worker is a fresh process and must be configured like the
79
88
  # parent — minus landlock, since it already runs inside the sandbox.
80
89
  config: { backend: config.backend, max_pixels: config.max_pixels }
@@ -87,6 +96,8 @@ module SafeImage
87
96
  def deep_symbolize(value)
88
97
  case value
89
98
  when Hash
99
+ # {"__sym__" => "x"} is a symbol value the parent wrapped for transport.
100
+ return value[:__sym__].to_sym if value.size == 1 && value[:__sym__].is_a?(String)
90
101
  value.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
91
102
  when Array
92
103
  value.map { |v| deep_symbolize(v) }
@@ -103,9 +114,9 @@ module SafeImage
103
114
  ]
104
115
  raise ArgumentError, "unsupported sandbox operation: #{operation}" unless allowed_operations.include?(operation)
105
116
 
106
- request = payload.fetch(:request)
117
+ request = deep_symbolize(payload.fetch(:request))
107
118
  args = request[:args] || []
108
- kwargs = deep_symbolize(request[:kwargs] || {})
119
+ kwargs = request[:kwargs] || {}
109
120
 
110
121
  config = payload.fetch(:config)
111
122
  SafeImage.configure!(
@@ -152,16 +163,7 @@ module SafeImage
152
163
  max_output_bytes: 512 * 1024,
153
164
  truncate_output: false
154
165
  )
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
166
+ decode_payload(JSON.parse(stdout, symbolize_names: true))
165
167
  end
166
168
  rescue LoadError
167
169
  raise Error, "landlock sandbox requested but the landlock gem is unavailable"
@@ -175,6 +177,31 @@ module SafeImage
175
177
  )
176
178
  end
177
179
 
180
+ # Rebuilds a worker's {__type:, data:} JSON reply into the value the
181
+ # caller would have received inline.
182
+ def decode_payload(response)
183
+ case response[:__type]
184
+ when "Result" then Result.new(**response.fetch(:data))
185
+ when "Info" then Info.new(**response.fetch(:data))
186
+ else response[:data]
187
+ end
188
+ end
189
+
190
+ # JSON cannot represent symbols, so wrap symbol values as {"__sym__" => name}
191
+ # for the worker's deep_symbolize to restore. Mirrors that decoder.
192
+ def deep_encode_symbols(value)
193
+ case value
194
+ when Symbol
195
+ { "__sym__" => value.to_s }
196
+ when Hash
197
+ value.transform_values { |v| deep_encode_symbols(v) }
198
+ when Array
199
+ value.map { |v| deep_encode_symbols(v) }
200
+ else
201
+ value
202
+ end
203
+ end
204
+
178
205
  def sandbox_paths(request, operation)
179
206
  read = []
180
207
  write = []