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
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Safe Image is a small Ruby image-processing boundary for untrusted uploads.
4
4
 
5
5
  It gives an application one narrow API for probing, thumbnailing, resizing,
6
- cropping, converting, optimising, SVG sanitising, animation checks, dominant
6
+ cropping, converting, optimising, SVG metadata probing, animation checks, dominant
7
7
  colour extraction, favicon conversion, and letter-avatar generation.
8
8
  Everything that varies by host is decided once, at boot, with a single
9
9
  mandatory call:
@@ -12,27 +12,27 @@ mandatory call:
12
12
  SafeImage.configure!(backend: :vips, landlock: true)
13
13
  ```
14
14
 
15
- The `:vips` backend uses a tiny Fiddle binding that drives `libvips` directly
16
- pure Ruby, nothing compiles at install time. The `:imagemagick` backend
17
- runs ImageMagick with shell-free command execution and a restrictive bundled
18
- policy. There are no per-call backend choices and no silent fallback from one
19
- backend to the other: you pick the decoder for untrusted bytes in one place
20
- and every operation uses it.
15
+ The `:vips` backend uses a tiny compiled helper for all libvips work; the Ruby
16
+ process never loads libvips. The `:imagemagick` backend runs ImageMagick with
17
+ shell-free command execution and a restrictive bundled policy. There are no
18
+ per-call backend choices and no silent fallback from one backend to the other:
19
+ you pick the decoder for untrusted bytes in one place and every operation uses
20
+ it.
21
21
 
22
22
  The premise is that hostile image bytes are a lousy thing to spread across
23
23
  model callbacks, upload helpers, optimizer wrappers, and hand-built command
24
24
  strings. Safe Image puts the risky operations behind one small, hardened
25
- choke point instead.
25
+ choke point instead. See [`docs/architecture.md`](docs/architecture.md) for the
26
+ security model and the invariants future changes must preserve.
26
27
 
27
28
  ## Install
28
29
 
29
- Nothing compiles at install time: libvips is bound at runtime through Fiddle
30
- (`libvips.so.42` is dlopened when `configure!(backend: :vips)` runs;
31
- `SAFE_IMAGE_LIBVIPS` overrides the library name authoritatively). libvips'
32
- GLib warnings about rejected input (e.g. "Not a PNG file") are silenced —
33
- failures surface as exceptions instead; set `SAFE_IMAGE_VIPS_WARNINGS=1` to
34
- restore them for debugging. Install the runtime
35
- [dependencies](#dependencies) below.
30
+ A small native helper is compiled at gem install time and linked against
31
+ libvips; install the libvips development package (`pkg-config vips` must work)
32
+ in the build environment. At runtime, `configure!(backend: :vips)` verifies that
33
+ this helper can start and initialize libvips; all subsequent libvips operations
34
+ run in helper subprocesses whose stderr is captured and reported only on
35
+ structured failures. Install the runtime [dependencies](#dependencies) below.
36
36
 
37
37
  ```bash
38
38
  gem build safe_image.gemspec
@@ -55,8 +55,8 @@ result = SafeImage.thumbnail(
55
55
  puts "#{result.backend}: #{result.width}x#{result.height} #{result.filesize} bytes"
56
56
 
57
57
  SafeImage.convert(
58
- "upload.png",
59
- "upload.jpg",
58
+ input: "upload.png",
59
+ output: "upload.jpg",
60
60
  format: "jpg",
61
61
  quality: 85,
62
62
  max_pixels: 40_000_000
@@ -66,13 +66,13 @@ SafeImage.convert(
66
66
  ## Configuration
67
67
 
68
68
  `SafeImage.configure!` must be called before any operation — typically from a
69
- boot-time initializer. Any operation before it (including the pure-Ruby SVG
70
- and remote helpers) raises `SafeImage::NotConfiguredError`.
69
+ boot-time initializer. Any operation before it (including SVG metadata and remote
70
+ helpers) raises `SafeImage::NotConfiguredError`.
71
71
 
72
72
  ```ruby
73
73
  SafeImage.configure!(
74
74
  backend: :vips, # required: :vips or :imagemagick — decodes all untrusted bytes
75
- landlock: true, # required: route every operation through the Landlock sandbox
75
+ landlock: true, # required: Landlock child helpers/tools when they run
76
76
  max_pixels: SafeImage::DEFAULT_MAX_PIXELS # optional: default decompression-bomb ceiling (128MP)
77
77
  )
78
78
  ```
@@ -80,7 +80,7 @@ SafeImage.configure!(
80
80
  Validation is eager, so a misconfigured host fails at boot rather than on the
81
81
  first request:
82
82
 
83
- - `backend: :vips` dlopens libvips and raises if it is unavailable
83
+ - `backend: :vips` verifies that the compiled helper can initialize libvips and raises if it is unavailable
84
84
  - `backend: :imagemagick` raises if no `magick`/`convert` executable is found
85
85
  - `landlock: true` raises if the Landlock sandbox is unavailable
86
86
  (`SafeImage.sandbox_available?` works before `configure!`, so a host can
@@ -98,8 +98,8 @@ is decided here and only here.
98
98
 
99
99
  Choosing a backend:
100
100
 
101
- - `:vips` — the recommended fast path: explicit native loaders, in-process
102
- hardening, no subprocess per operation
101
+ - `:vips` — the recommended fast path: explicit native loaders in the compiled
102
+ helper process, with no libvips loaded into Ruby
103
103
  - `:imagemagick` — matches classic ImageMagick `convert` pipelines; also the
104
104
  option for hosts without libvips. Formats are decoded by ImageMagick under
105
105
  the bundled restrictive policy.
@@ -116,7 +116,7 @@ Debian / Ubuntu:
116
116
 
117
117
  ```bash
118
118
  sudo apt-get install --no-install-recommends \
119
- libvips42 imagemagick jpegoptim pngquant oxipng libjpeg-turbo-progs
119
+ build-essential pkg-config libvips-dev imagemagick jpegoptim pngquant oxipng libjpeg-turbo-progs
120
120
  ```
121
121
 
122
122
  `oxipng` is packaged from Debian 13 / Ubuntu 24.04; on older releases install
@@ -127,7 +127,7 @@ detected at runtime.
127
127
  Arch:
128
128
 
129
129
  ```bash
130
- sudo pacman -S --needed libvips \
130
+ sudo pacman -S --needed base-devel pkgconf libvips \
131
131
  imagemagick jpegoptim pngquant oxipng libjpeg-turbo libjxl
132
132
  ```
133
133
 
@@ -137,18 +137,19 @@ sudo pacman -S --needed libvips \
137
137
 
138
138
  | Dependency | Kind | Needed for | Without it |
139
139
  | --- | --- | --- | --- |
140
- | `libvips` runtime library (`libvips.so.42`; Debian: `libvips42` ≥ 8.13) | required for `backend: :vips` | the fast path for every operation, bound via Fiddle | `configure!(backend: :vips)` raises at boot; configure `backend: :imagemagick` instead |
140
+ | `libvips` runtime library (`libvips.so.42`; Debian: `libvips42` ≥ 8.13) plus development headers/pkg-config at gem build time | required for `backend: :vips` | the fast path for every operation and the compiled helper | gem install fails without headers; `configure!(backend: :vips)` raises at boot if the runtime library is unavailable |
141
141
  | ImageMagick (`magick`/`convert`, `identify`) | required for `backend: :imagemagick` | every operation on the `:imagemagick` backend | `configure!(backend: :imagemagick)` raises at boot |
142
142
  | `jpegoptim` | required for JPEG `optimize` | lossless JPEG optimisation and metadata stripping | JPEG `optimize` raises in strict mode |
143
143
  | `oxipng` | required for PNG `optimize` | lossless PNG optimisation | PNG `optimize` raises in strict mode |
144
144
  | `pngquant` | optional | lossy PNG quantisation (`optimize_mode: :lossy`, files < 500KB) | lossy mode silently skips the quantisation pass |
145
- | `jpegtran` (libjpeg-turbo) | optional | lossless tier of `fix_orientation` for MCU-aligned JPEGs | falls back to the libvips re-encode tier |
145
+ | `jpegtran` (libjpeg-turbo) | optional | lossless tier of `fix_orientation`; uprighting EXIF-oriented JPEGs in `optimize` | `fix_orientation` falls back to the libvips re-encode tier; `optimize` of an oriented JPEG raises in strict mode (or copies source to output with `strict: false`) |
146
146
  | `cjpegli` (libjxl) | optional | higher-quality encoding of generated JPEGs on the `:vips` backend — used automatically when installed | generated JPEGs use the backend's own encoder |
147
- | `landlock` gem (Linux kernel ≥ 5.13) | required for `landlock: true` | the atomic sandbox around every operation | `configure!(landlock: true)` raises at boot; `sandbox_available?` is false |
148
- | `rexml` gem | automatic | SVG sanitising and SVG metadata | installed as a gem dependency |
147
+ | `landlock` gem ≥ 0.3 (Linux kernel ≥ 5.13) | required for `landlock: true` | optional sandboxing for child helpers/tools (`safe_image_vips_helper`, ImageMagick, optimizers, jpegtran/cjpegli) | `configure!(landlock: true)` raises at boot; `sandbox_available?` is false |
148
+ | `nokogiri` gem | automatic | bounded SVG metadata parser (not Landlock-contained) | installed as a gem dependency |
149
149
 
150
- The `landlock` gem is intentionally **not** a gem dependency; add it to the
151
- host application's Gemfile if you want sandboxing.
150
+ The `landlock` gem is intentionally **not** a runtime gem dependency; add
151
+ `gem "landlock", ">= 0.3"` to the host application's Gemfile if you want
152
+ sandboxing.
152
153
 
153
154
  ### libvips build capabilities
154
155
 
@@ -190,7 +191,7 @@ require "safe_image"
190
191
  %w[magick identify jpegoptim oxipng pngquant jpegtran cjpegli].each do |tool|
191
192
  puts format("%-10s %s", tool, SafeImage::Runner.available?(tool) ? "ok" : "missing")
192
193
  end
193
- puts format("%-10s %s", "libvips", SafeImage::VipsGlue.available? ? "ok" : "unavailable")
194
+ puts format("%-10s %s", "vips-helper", SafeImage::Native.available? ? "ok" : "unavailable")
194
195
  puts format("%-10s %s", "sandbox", SafeImage.sandbox_available? ? "ok" : "unavailable")
195
196
  ```
196
197
 
@@ -213,8 +214,8 @@ SafeImage::Result[
213
214
  width:,
214
215
  height:,
215
216
  filesize:,
216
- backend:, # e.g. "libvips-direct", "imagemagick", "cjpegli",
217
- # "libvips-direct+cjpegli"
217
+ backend:, # e.g. "libvips-helper", "imagemagick", "cjpegli",
218
+ # "libvips-helper+cjpegli"
218
219
  duration_ms:,
219
220
  optimizer: # optimizer tool list for thumbnail path, otherwise nil
220
221
  ]
@@ -228,7 +229,9 @@ Optimizer operations return a hash:
228
229
  before_bytes: 123_456,
229
230
  after_bytes: 120_000,
230
231
  saved_bytes: 3_456,
231
- tools: ["jpegoptim"]
232
+ tools: ["jpegoptim"],
233
+ rotated_from: nil, # the EXIF orientation baked into the pixels, when one was set
234
+ trimmed: false # true when uprighting dropped partial edge blocks (see optimize)
232
235
  }
233
236
  ```
234
237
 
@@ -289,7 +292,9 @@ SafeImage.size("icon.svg") # => [120, 80]
289
292
  SVG metadata is handled by a dedicated parser, not ImageMagick or libvips. It is
290
293
  limited to local `.svg` files, caps input size/tree depth/element/attribute
291
294
  counts, rejects `DOCTYPE` and non-XML processing instructions, requires an `<svg>`
292
- root, and derives dimensions from numeric `width`/`height` or `viewBox`.
295
+ root, and derives dimensions from numeric `width`/`height` or `viewBox`. This
296
+ bounded Nokogiri/libxml2 parse happens in the Ruby process; `landlock: true`
297
+ contains child image helpers/tools, not SVG metadata parsing itself.
293
298
 
294
299
  #### `SafeImage.orientation(path, max_pixels: nil)`
295
300
 
@@ -367,8 +372,26 @@ SafeImage.dominant_color("upload.png") # => "6F745E"
367
372
 
368
373
  These helpers are intended to cover `FastImage.size(url)` / `FastImage.type(url)`
369
374
  style use cases without another Ruby dependency. They use only Ruby stdlib
370
- `Net::HTTP`, download to a tempfile with a byte cap, then run the normal Safe
371
- Image local metadata path on that tempfile.
375
+ `Net::HTTP` and stream to a tempfile with a byte cap.
376
+
377
+ Like FastImage, the metadata helpers (`remote_size`, `remote_type`,
378
+ `remote_info`, `remote_animated?`) download as little as possible: the normal
379
+ Safe Image local metadata path probes the partially-downloaded tempfile as
380
+ bytes arrive (first at 64KB, then at growing thresholds) and the transfer is
381
+ aborted as soon as the answer is final — typically after the first 64KB.
382
+ Early answers are only trusted when more data cannot change them:
383
+
384
+ - "not animated" is reported only from the complete file, because a truncated
385
+ animation undercounts frames; "animated" is final as soon as a second frame
386
+ is seen
387
+ - SVG metadata always downloads the whole document, so the SVG parser's total
388
+ size cap keeps its meaning
389
+ - a probe failure on a prefix just means the download continues; a file that
390
+ never yields an early answer is downloaded and validated exactly like a
391
+ `fetch_remote` download
392
+
393
+ `fetch_remote` and `remote_dominant_color` need the complete body and always
394
+ download it (up to `max_bytes`).
372
395
 
373
396
  Remote fetching is deliberately conservative:
374
397
 
@@ -392,9 +415,14 @@ Remote fetching is deliberately conservative:
392
415
  - private, loopback, link-local, multicast, documentation, benchmarking,
393
416
  carrier-grade NAT, IPv4-mapped IPv6, NAT64, 6to4/Teredo, and other
394
417
  special-use resolved addresses are rejected by default
395
- - no image decoding happens directly from the socket
418
+ - no image decoding happens directly from the socket; probes only ever see the
419
+ on-disk tempfile
396
420
  - the final response `Content-Type` must be an allowed image type and must agree
397
- with an image-looking URL extension when one is present
421
+ with an image-looking URL extension when one is present — both are enforced
422
+ from the response headers, before any body bytes are downloaded
423
+ - the first bytes of the body must be compatible with the claimed format's
424
+ magic bytes (SVG, which has no signature, is exempt); an obviously mislabeled
425
+ body is dropped after the first chunk instead of being downloaded to the cap
398
426
  - downloaded content is probed before `fetch_remote` yields the tempfile, so the
399
427
  raw downloader cannot be used as a blind extension-based file saver
400
428
  - SVG remote metadata uses the same bounded SVG metadata parser after download;
@@ -461,15 +489,20 @@ SafeImage.fetch_remote("https://example.com/image.jpg", max_bytes: 10.megabytes)
461
489
  end
462
490
  ```
463
491
 
464
- With `landlock: true` configured, the network fetch itself is not put inside
465
- the Landlock worker because the worker denies network access. The downloaded
466
- tempfile is then passed through the normal Safe Image local image APIs, so
467
- decoding still uses the same sandboxed image-processing path.
492
+ With `landlock: true` configured, the network fetch itself is not sandboxed: it
493
+ needs network access, while the image-processing child helpers/tools deliberately
494
+ do not get it. The downloaded tempfile is then passed through the normal Safe
495
+ Image local APIs, so raster decoding and transform subprocesses still use the
496
+ configured Landlock containment.
468
497
 
469
498
  ### Generating images
470
499
 
471
500
  Operations that write a new image. All of them run on the configured backend
472
- and return [`SafeImage::Result`](#return-values).
501
+ and return [`SafeImage::Result`](#return-values). Every operation that reads an
502
+ existing image and writes a file requires explicit `input:` and `output:` paths;
503
+ they must be distinct. Safe Image does not expose in-place mutating APIs — if a
504
+ caller wants to replace a file, write to a separate destination and perform the
505
+ rename in application code.
473
506
 
474
507
  #### `SafeImage.thumbnail(...)`
475
508
 
@@ -499,46 +532,46 @@ Supported outputs on the `:vips` backend:
499
532
  - `avif`
500
533
  - `jxl` (requires a libvips build with libjxl support)
501
534
 
502
- #### `SafeImage.resize(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
535
+ #### `SafeImage.resize(input:, output:, width:, height:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
503
536
 
504
537
  Creates a resized thumbnail-style output.
505
538
 
506
539
  ```ruby
507
- SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400)
508
- SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400, quality: 85)
540
+ SafeImage.resize(input: "upload.jpg", output: "thumb.jpg", width: 600, height: 400)
541
+ SafeImage.resize(input: "upload.jpg", output: "thumb.jpg", width: 600, height: 400, quality: 85)
509
542
  ```
510
543
 
511
544
  `resize`, `crop` and `downsize` run on the configured backend:
512
545
 
513
- - `:vips` — the direct libvips path; formats outside the native loader
546
+ - `:vips` — the helper-backed libvips path; formats outside the native loader
514
547
  allowlist (ICO input) fail closed
515
548
  - `:imagemagick` — matches classic `convert` thumbnail pipelines
516
549
  (`-thumbnail`, catrom interpolation, unsharp, sRGB profile); configure this
517
550
  if byte-similar output with previously generated thumbnails matters
518
551
 
519
- #### `SafeImage.crop(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
552
+ #### `SafeImage.crop(input:, output:, width:, height:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
520
553
 
521
554
  Creates a north-cropped image — the shape typically used for square avatar
522
555
  crops.
523
556
 
524
557
  ```ruby
525
- SafeImage.crop("upload.jpg", "avatar.jpg", 240, 240)
558
+ SafeImage.crop(input: "upload.jpg", output: "avatar.jpg", width: 240, height: 240)
526
559
  ```
527
560
 
528
- #### `SafeImage.downsize(from, to, dimensions, optimize: true, max_pixels: nil, quality: 85, chroma_subsampling: :auto)`
561
+ #### `SafeImage.downsize(input:, output:, dimensions:, optimize: true, max_pixels: nil, quality: 85, chroma_subsampling: :auto)`
529
562
 
530
563
  Downsizes an image using ImageMagick-style geometry strings.
531
564
 
532
565
  ```ruby
533
- SafeImage.downsize("large.png", "small.png", "50%")
534
- SafeImage.downsize("large.png", "small.png", "100x100>")
535
- SafeImage.downsize("large.png", "small.png", "400000@")
566
+ SafeImage.downsize(input: "large.png", output: "small.png", dimensions: "50%")
567
+ SafeImage.downsize(input: "large.png", output: "small.png", dimensions: "100x100>")
568
+ SafeImage.downsize(input: "large.png", output: "small.png", dimensions: "400000@")
536
569
  ```
537
570
 
538
571
  The vips backend supports the geometry forms covered by the test suite:
539
572
  percentage, bounding box with `>`, and pixel-area cap with `@`.
540
573
 
541
- #### `SafeImage.convert(from, to, format:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
574
+ #### `SafeImage.convert(input:, output:, format:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
542
575
 
543
576
  Converts an input image to an explicit output `format:`. Unsupported formats
544
577
  raise `SafeImage::UnsupportedFormatError`.
@@ -555,12 +588,12 @@ For PNG-to-JPEG on the `:vips` backend, `cjpegli` is used automatically when
555
588
  installed (see [JPEG encoding of generated images](#jpeg-encoding-of-generated-images)).
556
589
 
557
590
  ```ruby
558
- SafeImage.convert("upload.png", "upload.jpg", format: "jpg", quality: 85)
559
- SafeImage.convert("upload.heic", "upload.jpg", format: "jpg", quality: 85)
560
- SafeImage.convert("upload.jpg", "upload.webp", format: "webp", quality: 85)
591
+ SafeImage.convert(input: "upload.png", output: "upload.jpg", format: "jpg", quality: 85)
592
+ SafeImage.convert(input: "upload.heic", output: "upload.jpg", format: "jpg", quality: 85)
593
+ SafeImage.convert(input: "upload.jpg", output: "upload.webp", format: "webp", quality: 85)
561
594
  ```
562
595
 
563
- #### `SafeImage.fix_orientation(from, to = from, max_pixels: nil, quality: nil)`
596
+ #### `SafeImage.fix_orientation(input:, output:, max_pixels: nil, quality: nil)`
564
597
 
565
598
  Bakes the EXIF orientation into the pixels and clears the tag. The `:vips`
566
599
  backend tries tiers in order:
@@ -574,26 +607,23 @@ backend tries tiers in order:
574
607
  The `:imagemagick` backend uses the previous `-auto-orient` behaviour and
575
608
  re-encodes at the input's estimated quality.
576
609
 
577
- If `to` is omitted, the file is rewritten in place.
578
-
579
610
  ```ruby
580
- SafeImage.fix_orientation("upload.jpg")
581
- SafeImage.fix_orientation("upload.jpg", "oriented.jpg")
611
+ SafeImage.fix_orientation(input: "upload.jpg", output: "oriented.jpg")
582
612
  ```
583
613
 
584
- #### `SafeImage.convert_favicon_to_png(from, to, optimize: true, max_pixels: nil)`
614
+ #### `SafeImage.convert_favicon_to_png(input:, output:, optimize: true, max_pixels: nil)`
585
615
 
586
616
  Extracts the largest ICO entry and writes PNG. On the `:vips` backend no
587
617
  ImageMagick is involved: the container and legacy DIB payloads (1/4/8/24/32bpp
588
618
  BI_RGB plus the AND mask) are parsed in pure Ruby with explicit bounds checks,
589
- and pixels are encoded through the hardened native libvips path. Embedded PNG
619
+ and pixels are encoded through the hardened libvips helper. Embedded PNG
590
620
  payloads are re-encoded — never copied through verbatim — and their pixel cap
591
621
  is enforced from the IHDR before any decoder runs. On the `:imagemagick`
592
622
  backend the conversion runs through ImageMagick's ico decoder under the
593
623
  bundled policy.
594
624
 
595
625
  ```ruby
596
- SafeImage.convert_favicon_to_png("favicon.ico", "favicon.png")
626
+ SafeImage.convert_favicon_to_png(input: "favicon.ico", output: "favicon.png")
597
627
  ```
598
628
 
599
629
  #### `SafeImage.letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")`
@@ -633,17 +663,18 @@ JPEGs**. This avoids hiding a lossy re-encode behind a method named `optimize`.
633
663
 
634
664
  Like the optimizer tools, the optional `cjpegli` encoder is availability
635
665
  driven: installed means used, absent means the configured backend encodes.
636
- There is no encoder knob cjpegli only ever encodes pixels Safe Image has
637
- already decoded, so it is not part of the untrusted-input surface the backend
638
- choice controls.
666
+ There is no encoder knob. Even when cjpegli is used, untrusted inputs are first
667
+ decoded by the configured Safe Image backend with the normal pixel cap; cjpegli
668
+ only receives a PNG generated by Safe Image, so it is not an additional
669
+ untrusted-input decoder.
639
670
 
640
671
  | Operation | Behavior |
641
672
  | --- | --- |
642
- | `thumbnail` / `resize` / `crop` / `downsize` to JPEG on the `:vips` backend | use `cjpegli` when installed; otherwise normal libvips JPEG output |
643
- | `convert("input.png", "output.jpg", format: "jpg")` on the `:vips` backend | use `cjpegli` when installed (PNG is the one input Jpegli encodes directly); otherwise libvips |
644
- | `convert` from HEIC/WebP/AVIF/GIF/JPEG to JPEG | decode through the native libvips loaders and encode with libvips; `cjpegli` is not treated as a universal decoder |
673
+ | `thumbnail` / `resize` / `crop` / `downsize` to JPEG on the `:vips` backend | decode through libvips first, then use `cjpegli` when installed; otherwise normal libvips JPEG output |
674
+ | `convert(input: "input.png", output: "output.jpg", format: "jpg")` on the `:vips` backend | decode the PNG through libvips into a generated PNG first, then use `cjpegli` when installed; otherwise libvips |
675
+ | `convert` from HEIC/WebP/AVIF/GIF/JPEG/JXL to JPEG | decode through the native libvips loaders and encode with libvips; `cjpegli` is not treated as a universal decoder |
645
676
  | any operation on the `:imagemagick` backend | ImageMagick encodes; `cjpegli` is never used |
646
- | `optimize("existing.jpg")` | use `jpegoptim`; never `cjpegli` |
677
+ | `optimize(input: "existing.jpg", output: "optimized.jpg")` | use `jpegoptim`; never `cjpegli` |
647
678
 
648
679
  `cjpegli` output is ordinary browser-compatible JPEG. It is optional because it
649
680
  is a system binary, not a Ruby dependency. Safe Image detects it at runtime.
@@ -651,16 +682,17 @@ is a system binary, not a Ruby dependency. Safe Image detects it at runtime.
651
682
  `chroma_subsampling: :auto` uses `4:4:4` for PNG-sourced JPEG conversion and
652
683
  `4:2:0` otherwise. Pass `"420"`, `"422"`, or `"444"` to force a value.
653
684
 
654
- ### Optimising in place
685
+ ### Optimising to a destination
655
686
 
656
- #### `SafeImage.optimize(path, mode: :lossless, strip_metadata: true, quality: nil, strict: true)`
687
+ #### `SafeImage.optimize(input:, output:, mode: :lossless, strip_metadata: true, quality: nil, strict: true)`
657
688
 
658
- Optimises an existing JPEG or PNG in place.
689
+ Optimises an existing JPEG or PNG into a separate output path. The source file is
690
+ never modified, and `input:`/`output:` must not refer to the same file.
659
691
 
660
692
  ```ruby
661
- SafeImage.optimize("image.jpg", quality: 85)
662
- SafeImage.optimize("image.png")
663
- SafeImage.optimize("image.png", mode: :lossy, quality: "65-90")
693
+ SafeImage.optimize(input: "image.jpg", output: "image.optimized.jpg", quality: 85)
694
+ SafeImage.optimize(input: "image.png", output: "image.optimized.png")
695
+ SafeImage.optimize(input: "image.png", output: "image.lossy.png", mode: :lossy, quality: "65-90")
664
696
  ```
665
697
 
666
698
  JPEG path:
@@ -668,6 +700,14 @@ JPEG path:
668
700
  - uses `jpegoptim`
669
701
  - `quality:` maps to `jpegoptim --max`
670
702
  - metadata is stripped unless `strip_metadata: false`
703
+ - an EXIF-oriented JPEG is uprighted first with jpegtran's lossless
704
+ transforms, because stripping would otherwise delete the orientation tag
705
+ without applying the rotation and ship the image sideways. MCU-aligned
706
+ images rotate exactly (`-perfect`); others drop the partial edge blocks
707
+ (`-trim`, under one MCU — at most 15px), reported as `trimmed: true` in
708
+ the result rather than re-encoding behind a method named `optimize`.
709
+ Without `jpegtran`, an oriented JPEG raises in strict mode and the output is
710
+ not written; with `strict: false`, the original bytes are copied to `output:`.
671
711
 
672
712
  PNG path:
673
713
 
@@ -676,44 +716,18 @@ PNG path:
676
716
  then `oxipng`
677
717
 
678
718
  When `strict: true`, missing optimizer tools raise. When `strict: false`, missing
679
- optimizer tools are tolerated.
680
-
681
- ### SVG sanitising
682
-
683
- #### `SafeImage.sanitize_svg!(path)`
684
-
685
- Sanitises an SVG in place using a small REXML allowlist.
686
-
687
- ```ruby
688
- result = SafeImage.sanitize_svg!("icon.svg")
689
- puts result[:sanitized]
690
- ```
719
+ optimizer tools are tolerated and the output is still written from the source
720
+ copy when possible.
691
721
 
692
- The sanitizer removes unsafe elements/attributes such as scripts and event
693
- handlers. It is intentionally conservative rather than a full browser-grade SVG
694
- implementation.
722
+ ### SVG handling
695
723
 
696
- SVG sanitising is defense-in-depth for stored bytes. Applications that serve
697
- user-supplied SVGs directly should still use response-level controls such as a
724
+ Safe Image no longer sanitises or rewrites SVG files. It only supports bounded
725
+ metadata probing for local and remote `.svg` inputs (`type`, `size`, `info`, and
726
+ the corresponding remote helpers). Applications that accept user-supplied SVG
727
+ content for display should run a dedicated SVG sanitizer in their own upload or
728
+ rendering pipeline, and should still use response-level controls such as a
698
729
  restrictive `Content-Security-Policy`, `X-Content-Type-Options: nosniff`, and/or
699
- attachment/sandbox handling for direct-open routes. Browsers restrict script
700
- execution when an SVG is embedded as `<img>`, but a top-level SVG document is a
701
- different sink.
702
-
703
- ### Compatibility aliases
704
-
705
- Two thin wrappers kept for callers migrating from existing upload pipelines:
706
-
707
- ```ruby
708
- SafeImage.optimize_image!("image.jpg")
709
- SafeImage.optimize_image!("image.png", allow_lossy_png: true)
710
- SafeImage.convert_to_jpeg("upload.heic", "upload.jpg", quality: 85)
711
- ```
712
-
713
- `optimize_image!(path, allow_lossy_png: false, strip_metadata: true, quality: nil, strict: true)`
714
- forwards to `optimize`, with `allow_lossy_png:` mapping to `mode:`.
715
- `convert_to_jpeg(from, to, ...)` forwards to `convert` with `format: "jpg"`
716
- and accepts the same keywords.
730
+ attachment/sandbox handling for direct-open routes.
717
731
 
718
732
  ## Security
719
733
 
@@ -730,12 +744,12 @@ What it does:
730
744
  - starts external commands with an allowlisted environment, private temp/home/cache
731
745
  directories, bounded stdout/stderr, and process-group timeout cleanup
732
746
  - uses explicit libvips loaders selected from allowlisted extensions
733
- - enables libvips' untrusted-operation block in-process (deliberately
747
+ - enables libvips' untrusted-operation block inside the helper (deliberately
734
748
  re-enabling only the libjxl loader/saver, which libvips tags untrusted,
735
749
  because JPEG XL is part of the supported input surface)
736
- - blocks libvips ImageMagick loader classes in the libvips binding, which
737
- itself exposes only the operations the gem invokes
738
- - disables libvips cache by default in-process
750
+ - blocks libvips ImageMagick loader classes in the helper, which exposes only
751
+ the operations the gem invokes
752
+ - disables libvips cache inside the helper
739
753
  - strips metadata on generated images where applicable
740
754
  - rejects symlinked local input/output paths and symlinked path components for
741
755
  untrusted file-processing paths
@@ -752,12 +766,8 @@ What it does:
752
766
  blocking, DNS pinning, redirect limits, HTTPS-to-HTTP rejection, proxy-env
753
767
  bypass prevention, request-header allowlists, content-type/extension
754
768
  agreement, and probe-before-yield (details under [Remote URLs](#remote-urls))
755
- - parses SVG metadata with a bounded pure-Ruby parser; SVG is never handed to
769
+ - parses SVG metadata with a bounded Nokogiri SAX parser; SVG is never handed to
756
770
  ImageMagick for probing
757
- - sanitises SVG conservatively, allowlist based: rejects `DOCTYPE` and XML
758
- processing instructions, removes comments and disallowed elements, converts
759
- CDATA to escaped text, and blocks event handlers, external URLs, and
760
- `javascript:` / `data:` URL values
761
771
  - supports optional Landlock subprocess sandboxing on Linux
762
772
 
763
773
  The backend is a configuration decision, not a per-call option. Safe Image
@@ -780,23 +790,23 @@ ImageMagick path runs with:
780
790
 
781
791
  That does **not** make hostile files benign. Raster decoders still parse attacker
782
792
  controlled bytes: libjpeg, libpng, libwebp, libheif/HEIC/AVIF, ImageMagick's
783
- raster decoders, and libvips loaders. If one of those decoders has a memory
784
- corruption bug or pathological resource-consumption bug, policy alone is not a
785
- sandbox.
793
+ raster decoders, and libvips loaders; Nokogiri/libxml2 also parses SVG metadata.
794
+ If one of those parsers or decoders has a memory corruption bug or pathological
795
+ resource-consumption bug, policy alone is not a sandbox.
786
796
 
787
797
  So the intended posture is:
788
798
 
789
799
  - without Landlock: hardened, centralized image processing with the major
790
800
  ImageMagick delegate/pseudo-protocol foot-guns removed
791
- - with Landlock: the same hardening plus a real containment boundary around all
792
- public operations
801
+ - with Landlock: the same hardening plus a containment boundary around child
802
+ helpers/tools that parse or transform raster bytes
793
803
 
794
804
  Do not describe non-sandboxed operation as making hostile images safe. The honest
795
805
  claim is defense-in-depth, not immunity.
796
806
 
797
- ### Atomic Landlock sandboxing
807
+ ### Optional Landlock containment
798
808
 
799
- Landlock support is optional, but atomic once configured.
809
+ Landlock support is optional, but fail-closed once configured.
800
810
 
801
811
  ```ruby
802
812
  SafeImage.sandbox_available? # => true/false, works before configure!
@@ -804,46 +814,44 @@ SafeImage.configure!(backend: :vips, landlock: true) # raises if unavailable
804
814
  SafeImage.config.landlock # => true
805
815
  ```
806
816
 
807
- With `landlock: true`, every public operation routes through the sandbox
808
- worker:
809
-
810
- - `probe`
811
- - `type`
812
- - `size`
813
- - `dimensions`
814
- - `info`
815
- - `orientation`
816
- - `dominant_color`
817
- - `thumbnail`
818
- - `optimize`
819
- - `resize`
820
- - `crop`
821
- - `downsize`
822
- - `convert`
823
- - `convert_to_jpeg` compatibility alias
824
- - `fix_orientation`
825
- - `convert_favicon_to_png`
826
- - `frame_count`
827
- - `animated?`
828
- - `letter_avatar`
829
- - `optimize_image!`
830
- - `sanitize_svg!`
831
-
832
- There is no silent fallback once landlock is configured. If sandbox setup or
833
- a sandboxed command fails, the operation fails.
834
-
835
- The sandbox grants read/write access only to the paths inferred from the
836
- operation arguments, plus runtime/library paths and temporary directories needed
837
- by Ruby, libvips, ImageMagick, and optimizer tools. Network syscalls are denied
838
- through the Landlock helper's seccomp layer. Worker processes inherit the
839
- parent's backend and pixel-ceiling configuration; landlock is forced off
840
- inside the worker so sandboxed operations never nest.
817
+ With `landlock: true`, Safe Image does **not** proxy public operations through a
818
+ sandboxed Ruby worker. Ruby remains the orchestrator: it validates paths, stages
819
+ outputs, applies format/pixel policy, parses bounded SVG/ICO metadata where
820
+ needed, and launches the actual image-processing helpers/tools.
821
+
822
+ The Landlock boundary is applied to child processes that do risky native image
823
+ work:
824
+
825
+ - `safe_image_vips_helper` for every libvips operation
826
+ - ImageMagick `magick`/`convert`/`identify` commands on the `:imagemagick` backend
827
+ - optimizer tools such as `jpegoptim`, `oxipng`, and `pngquant`
828
+ - `jpegtran` and `cjpegli` when those optional paths are used
829
+
830
+ There is no silent fallback once Landlock is configured. If sandbox setup or a
831
+ sandboxed helper/tool fails, the operation fails.
832
+
833
+ Each child process receives only the explicit read/write grants for that call,
834
+ plus runtime/library/font/temp paths needed by the helper or tool. The network is
835
+ denied for those children; remote fetching happens in Ruby before the downloaded
836
+ tempfile is handed back to the local image APIs.
837
+
838
+ For the `:vips` backend, raster operations are executed by the compiled
839
+ `safe_image_vips_helper` executable. The Ruby process performs path validation and
840
+ orchestration, then the helper initializes libvips in its own process, applies the
841
+ loader/operation allowlist and pixel cap, writes a structured JSON response, and
842
+ exits. With Landlock enabled, the helper process itself is launched inside the
843
+ per-call filesystem policy.
844
+
845
+ SVG metadata is the deliberate exception: it is bounded and non-rendering, but it
846
+ is parsed by Nokogiri/libxml2 in the Ruby process and is not Landlock-contained by
847
+ Safe Image. If your deployment needs a hard kernel boundary around SVG parsing as
848
+ well, run Safe Image in a separate application process/container.
841
849
 
842
850
  ## Development
843
851
 
844
852
  ```bash
845
853
  bundle install
846
- bundle exec rake # run the tests (nothing compiles)
854
+ bundle exec rake # run the tests (builds/uses the native vips helper)
847
855
  docker/run.sh # run the suite on Debian bookworm's packaged libvips 8.14
848
856
  bundle exec rubocop # lint
849
857
  ```
@@ -855,8 +863,8 @@ explanation when that support is missing.
855
863
 
856
864
  The suite includes golden-output checks, cross-backend parity checks (the
857
865
  claim is operation parity, not byte-for-byte output identity across
858
- ImageMagick/libvips versions), policy-denial checks, and a real-image atomic
859
- sandbox sweep over the full public operation list.
866
+ ImageMagick/libvips versions), policy-denial checks, and Landlock sweeps over
867
+ helper/tool execution paths.
860
868
 
861
869
  Gem packaging uses the standard Bundler tasks (`rake build`, `rake install`).
862
870
  Releases: bump `SafeImage::VERSION`, merge to `main`, and CI publishes the