safe_image 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -12
  3. data/README.md +140 -287
  4. data/SECURITY.md +22 -11
  5. data/docs/architecture.md +63 -0
  6. data/ext/safe_image_vips_helper/extconf.rb +43 -0
  7. data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
  8. data/lib/safe_image/api/metadata.rb +85 -0
  9. data/lib/safe_image/api/transform.rb +152 -0
  10. data/lib/safe_image/backend_label.rb +24 -0
  11. data/lib/safe_image/formats.rb +96 -0
  12. data/lib/safe_image/ico.rb +42 -40
  13. data/lib/safe_image/image_magick_backend.rb +219 -162
  14. data/lib/safe_image/jpegli_backend.rb +64 -44
  15. data/lib/safe_image/metadata_operations.rb +155 -0
  16. data/lib/safe_image/native.rb +96 -290
  17. data/lib/safe_image/native_helper.rb +281 -0
  18. data/lib/safe_image/operation_backends/base.rb +83 -0
  19. data/lib/safe_image/operation_backends/image_magick.rb +123 -0
  20. data/lib/safe_image/operation_backends/vips.rb +251 -0
  21. data/lib/safe_image/operation_backends.rb +27 -0
  22. data/lib/safe_image/operation_set.rb +22 -0
  23. data/lib/safe_image/optimizer.rb +225 -98
  24. data/lib/safe_image/path_safety.rb +11 -0
  25. data/lib/safe_image/processor.rb +122 -85
  26. data/lib/safe_image/quality_defaults.rb +23 -0
  27. data/lib/safe_image/remote.rb +248 -144
  28. data/lib/safe_image/result.rb +71 -23
  29. data/lib/safe_image/runner.rb +60 -23
  30. data/lib/safe_image/sandbox.rb +44 -218
  31. data/lib/safe_image/staged_output.rb +44 -0
  32. data/lib/safe_image/svg_metadata.rb +74 -69
  33. data/lib/safe_image/transform_operations.rb +139 -0
  34. data/lib/safe_image/version.rb +1 -1
  35. data/lib/safe_image/vips_backend.rb +13 -12
  36. data/lib/safe_image.rb +62 -306
  37. metadata +43 -37
  38. data/lib/safe_image/discourse_compat.rb +0 -441
  39. data/lib/safe_image/svg_css.rb +0 -314
  40. data/lib/safe_image/svg_sanitizer.rb +0 -583
  41. data/lib/safe_image/vips_glue.rb +0 -361
  42. data/lib/safe_image/zygote.rb +0 -619
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`; 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 (left untouched otherwise) |
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
  ]
@@ -291,7 +292,9 @@ SafeImage.size("icon.svg") # => [120, 80]
291
292
  SVG metadata is handled by a dedicated parser, not ImageMagick or libvips. It is
292
293
  limited to local `.svg` files, caps input size/tree depth/element/attribute
293
294
  counts, rejects `DOCTYPE` and non-XML processing instructions, requires an `<svg>`
294
- 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.
295
298
 
296
299
  #### `SafeImage.orientation(path, max_pixels: nil)`
297
300
 
@@ -486,15 +489,20 @@ SafeImage.fetch_remote("https://example.com/image.jpg", max_bytes: 10.megabytes)
486
489
  end
487
490
  ```
488
491
 
489
- With `landlock: true` configured, the network fetch itself is not put inside
490
- the Landlock worker because the worker denies network access. The downloaded
491
- tempfile is then passed through the normal Safe Image local image APIs, so
492
- 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.
493
497
 
494
498
  ### Generating images
495
499
 
496
500
  Operations that write a new image. All of them run on the configured backend
497
- 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.
498
506
 
499
507
  #### `SafeImage.thumbnail(...)`
500
508
 
@@ -524,46 +532,46 @@ Supported outputs on the `:vips` backend:
524
532
  - `avif`
525
533
  - `jxl` (requires a libvips build with libjxl support)
526
534
 
527
- #### `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)`
528
536
 
529
537
  Creates a resized thumbnail-style output.
530
538
 
531
539
  ```ruby
532
- SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400)
533
- 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)
534
542
  ```
535
543
 
536
544
  `resize`, `crop` and `downsize` run on the configured backend:
537
545
 
538
- - `:vips` — the direct libvips path; formats outside the native loader
546
+ - `:vips` — the helper-backed libvips path; formats outside the native loader
539
547
  allowlist (ICO input) fail closed
540
548
  - `:imagemagick` — matches classic `convert` thumbnail pipelines
541
549
  (`-thumbnail`, catrom interpolation, unsharp, sRGB profile); configure this
542
550
  if byte-similar output with previously generated thumbnails matters
543
551
 
544
- #### `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)`
545
553
 
546
554
  Creates a north-cropped image — the shape typically used for square avatar
547
555
  crops.
548
556
 
549
557
  ```ruby
550
- SafeImage.crop("upload.jpg", "avatar.jpg", 240, 240)
558
+ SafeImage.crop(input: "upload.jpg", output: "avatar.jpg", width: 240, height: 240)
551
559
  ```
552
560
 
553
- #### `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)`
554
562
 
555
563
  Downsizes an image using ImageMagick-style geometry strings.
556
564
 
557
565
  ```ruby
558
- SafeImage.downsize("large.png", "small.png", "50%")
559
- SafeImage.downsize("large.png", "small.png", "100x100>")
560
- 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@")
561
569
  ```
562
570
 
563
571
  The vips backend supports the geometry forms covered by the test suite:
564
572
  percentage, bounding box with `>`, and pixel-area cap with `@`.
565
573
 
566
- #### `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)`
567
575
 
568
576
  Converts an input image to an explicit output `format:`. Unsupported formats
569
577
  raise `SafeImage::UnsupportedFormatError`.
@@ -580,12 +588,12 @@ For PNG-to-JPEG on the `:vips` backend, `cjpegli` is used automatically when
580
588
  installed (see [JPEG encoding of generated images](#jpeg-encoding-of-generated-images)).
581
589
 
582
590
  ```ruby
583
- SafeImage.convert("upload.png", "upload.jpg", format: "jpg", quality: 85)
584
- SafeImage.convert("upload.heic", "upload.jpg", format: "jpg", quality: 85)
585
- 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)
586
594
  ```
587
595
 
588
- #### `SafeImage.fix_orientation(from, to = from, max_pixels: nil, quality: nil)`
596
+ #### `SafeImage.fix_orientation(input:, output:, max_pixels: nil, quality: nil)`
589
597
 
590
598
  Bakes the EXIF orientation into the pixels and clears the tag. The `:vips`
591
599
  backend tries tiers in order:
@@ -599,26 +607,23 @@ backend tries tiers in order:
599
607
  The `:imagemagick` backend uses the previous `-auto-orient` behaviour and
600
608
  re-encodes at the input's estimated quality.
601
609
 
602
- If `to` is omitted, the file is rewritten in place.
603
-
604
610
  ```ruby
605
- SafeImage.fix_orientation("upload.jpg")
606
- SafeImage.fix_orientation("upload.jpg", "oriented.jpg")
611
+ SafeImage.fix_orientation(input: "upload.jpg", output: "oriented.jpg")
607
612
  ```
608
613
 
609
- #### `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)`
610
615
 
611
616
  Extracts the largest ICO entry and writes PNG. On the `:vips` backend no
612
617
  ImageMagick is involved: the container and legacy DIB payloads (1/4/8/24/32bpp
613
618
  BI_RGB plus the AND mask) are parsed in pure Ruby with explicit bounds checks,
614
- and pixels are encoded through the hardened native libvips path. Embedded PNG
619
+ and pixels are encoded through the hardened libvips helper. Embedded PNG
615
620
  payloads are re-encoded — never copied through verbatim — and their pixel cap
616
621
  is enforced from the IHDR before any decoder runs. On the `:imagemagick`
617
622
  backend the conversion runs through ImageMagick's ico decoder under the
618
623
  bundled policy.
619
624
 
620
625
  ```ruby
621
- SafeImage.convert_favicon_to_png("favicon.ico", "favicon.png")
626
+ SafeImage.convert_favicon_to_png(input: "favicon.ico", output: "favicon.png")
622
627
  ```
623
628
 
624
629
  #### `SafeImage.letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")`
@@ -658,17 +663,18 @@ JPEGs**. This avoids hiding a lossy re-encode behind a method named `optimize`.
658
663
 
659
664
  Like the optimizer tools, the optional `cjpegli` encoder is availability
660
665
  driven: installed means used, absent means the configured backend encodes.
661
- There is no encoder knob cjpegli only ever encodes pixels Safe Image has
662
- already decoded, so it is not part of the untrusted-input surface the backend
663
- 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.
664
670
 
665
671
  | Operation | Behavior |
666
672
  | --- | --- |
667
- | `thumbnail` / `resize` / `crop` / `downsize` to JPEG on the `:vips` backend | use `cjpegli` when installed; otherwise normal libvips JPEG output |
668
- | `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 |
669
- | `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 |
670
676
  | any operation on the `:imagemagick` backend | ImageMagick encodes; `cjpegli` is never used |
671
- | `optimize("existing.jpg")` | use `jpegoptim`; never `cjpegli` |
677
+ | `optimize(input: "existing.jpg", output: "optimized.jpg")` | use `jpegoptim`; never `cjpegli` |
672
678
 
673
679
  `cjpegli` output is ordinary browser-compatible JPEG. It is optional because it
674
680
  is a system binary, not a Ruby dependency. Safe Image detects it at runtime.
@@ -676,16 +682,17 @@ is a system binary, not a Ruby dependency. Safe Image detects it at runtime.
676
682
  `chroma_subsampling: :auto` uses `4:4:4` for PNG-sourced JPEG conversion and
677
683
  `4:2:0` otherwise. Pass `"420"`, `"422"`, or `"444"` to force a value.
678
684
 
679
- ### Optimising in place
685
+ ### Optimising to a destination
680
686
 
681
- #### `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)`
682
688
 
683
- 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.
684
691
 
685
692
  ```ruby
686
- SafeImage.optimize("image.jpg", quality: 85)
687
- SafeImage.optimize("image.png")
688
- 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")
689
696
  ```
690
697
 
691
698
  JPEG path:
@@ -699,8 +706,8 @@ JPEG path:
699
706
  images rotate exactly (`-perfect`); others drop the partial edge blocks
700
707
  (`-trim`, under one MCU — at most 15px), reported as `trimmed: true` in
701
708
  the result rather than re-encoding behind a method named `optimize`.
702
- Without `jpegtran`, an oriented JPEG raises in strict mode and is left
703
- untouched otherwise never stripped sideways.
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:`.
704
711
 
705
712
  PNG path:
706
713
 
@@ -709,138 +716,18 @@ PNG path:
709
716
  then `oxipng`
710
717
 
711
718
  When `strict: true`, missing optimizer tools raise. When `strict: false`, missing
712
- optimizer tools are tolerated.
713
-
714
- ### SVG sanitising
719
+ optimizer tools are tolerated and the output is still written from the source
720
+ copy when possible.
715
721
 
716
- #### `SafeImage.sanitize_svg!(path, id_namespace:)`
722
+ ### SVG handling
717
723
 
718
- Sanitises an SVG in place using a small REXML allowlist. `id_namespace:` is
719
- **required** it forces a deliberate choice of where the output may be used,
720
- so there is no silently-wrong default (see "Inlining" below):
721
-
722
- ```ruby
723
- # served as an <img src>/CSS-url/file and never spliced into a page's DOM:
724
- result = SafeImage.sanitize_svg!("icon.svg", id_namespace: :standalone)
725
-
726
- # spliced inline into an HTML DOM (pass a stable, per-document token):
727
- result = SafeImage.sanitize_svg!("icon.svg", id_namespace: "u#{upload.sha1}")
728
-
729
- puts result[:sanitized]
730
- ```
731
-
732
- Omitting `id_namespace:` (or passing `nil`/`""`) raises `ArgumentError`.
733
-
734
- The sanitizer removes unsafe elements/attributes such as scripts and event
735
- handlers. It is intentionally conservative rather than a full browser-grade SVG
736
- implementation.
737
-
738
- CSS is reduced to a constructed allowlist subset rather than stripped: `style`
739
- attributes (as written by Inkscape) and `<style>` elements (as written by
740
- Illustrator) survive when they parse against a small grammar — allowlisted
741
- properties, type/class/id selectors, numeric/keyword/color values, and
742
- `url(#fragment)` references only. The output is reassembled from validated
743
- tokens, never echoed from the input; escapes, quotes, at-rules (`@import`,
744
- `@font-face`, `@media`), comments, strings, and unknown
745
- properties/functions/selectors drop the declaration, rule, or whole stylesheet
746
- rather than being interpreted.
747
-
748
- Two behaviours are worth knowing before relying on this:
749
-
750
- - **The CSS property allowlist mirrors the presentation attributes that have
751
- CSS-property twins** — `SvgCss::ALLOWED_PROPERTIES` is a subset of
752
- `SvgSanitizer::ALLOWED_ATTRIBUTES` (asserted by a test), so a `fill:`
753
- declaration and a `fill=""` attribute are treated identically and a property
754
- the sanitizer would strip as an attribute is also dropped in CSS. (The
755
- reverse does not hold: geometry/XML attributes like `width`, `href`, and
756
- `xmlns` are not CSS properties.) The set covers the common paint, stroke
757
- (including `stroke-dasharray` and `vector-effect`), marker, text, and
758
- visibility properties that Inkscape and Illustrator emit; it is deliberately
759
- narrower than a browser. Filters (`filter`, `fe*`) are not yet included.
760
- - **A `<style>` element fails closed as a whole** on anything outside a flat
761
- list of `selector { declarations }` rules. Any at-rule (e.g. one stray
762
- `@import`), a nested block, or an unbalanced brace discards every rule in that
763
- element, not just the offending one. Within a well-formed stylesheet,
764
- individual selectors and declarations still drop independently.
765
-
766
- SVG sanitising is defense-in-depth for stored bytes. Applications that serve
767
- 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
768
729
  restrictive `Content-Security-Policy`, `X-Content-Type-Options: nosniff`, and/or
769
- attachment/sandbox handling for direct-open routes. Browsers restrict script
770
- execution when an SVG is embedded as `<img>`, but a top-level SVG document is a
771
- different sink.
772
-
773
- #### Inlining sanitized SVG into an HTML page
774
-
775
- The `id_namespace:` argument forces this decision at every call site — there is
776
- no default to get wrong.
777
-
778
- Pass `:standalone` when the output is only ever served as an external resource —
779
- `<img src>`, `background-image`, an `<object>`/`<iframe>`, or its own file. This
780
- is the document-safe form. It is **not** safe to splice directly into an HTML
781
- DOM: a preserved `<style>` rule like `*{visibility:hidden}` or
782
- `#header{display:none}` would join the host document's cascade, and the SVG's
783
- `id`s could clobber host ids — both CSS-injection / UI-redress vectors.
784
-
785
- Pass a **stable, per-document** String (e.g. the upload's sha) to make the output
786
- safe to inline:
787
-
788
- ```ruby
789
- SafeImage.sanitize_svg!("icon.svg", id_namespace: "u#{upload.sha1}")
790
- ```
791
-
792
- With a namespace, the sanitizer:
793
-
794
- - prefixes every `id` and every reference to it — `href`/`xlink:href` fragments,
795
- `url(#…)` in attributes and CSS, and ARIA IDREF attributes (`aria-labelledby`,
796
- `aria-describedby`, `aria-controls`, …) — so internal references stay intact
797
- but cannot collide with host ids; and
798
- - prefixes every `class` token (and the `.class` selectors that match them), so
799
- an attacker can't invoke the host page's framework CSS — a bare
800
- `class="modal fixed"` would otherwise pick up Bootstrap/Tailwind/app styles and
801
- become an overlay. Internal class styling still matches because attribute and
802
- selector are prefixed together; and
803
- - scopes every `<style>` selector under a `<ns>-scope` class it adds to the root
804
- `<svg>`, so `*` and type selectors only match that document's own content and
805
- can never reach the host page; and
806
- - rejects `var()`, `env()`, and `attr()` in presentation attributes — they
807
- resolve against the host page (custom properties, environment) and could pull
808
- in values, including a `url()`, the sanitizer never saw; and
809
- - drops `overflow` from the root `<svg>` so it clips to its declared viewport — a
810
- tiny `width`/`height` with `overflow:visible` and oversized content would
811
- otherwise paint a full-page overlay. Inner elements keep `overflow` (markers
812
- need it); the root clip bounds them.
813
-
814
- Because every `<style>` selector is anchored *under* the scope class, a rule
815
- targeting the root itself — `svg { … }`, `* { … }` intended to include the root,
816
- or a class on the root such as `.icon { … }` for `<svg class="icon">` — matches
817
- the root's descendants but not the root element. Root-level styling from a
818
- `<style>` block therefore does not survive; style the root via attributes if you
819
- need it. (This is rare in editor exports, which style the root with attributes
820
- and inner elements with classes.)
821
-
822
- `style=""` attributes never need selector scoping — a declaration list only
823
- styles its own element — so they are not a cascade risk in either mode. They can
824
- still carry `url(#…)` references, though, which are only namespaced when you pass
825
- a String; so `:standalone` output (bare ids and references) is still not for
826
- inline use. The transform is idempotent for a given namespace, so re-sanitising
827
- is a no-op. Use a per-document value so two inlined SVGs on one page don't share
828
- a namespace.
829
-
830
- ### Compatibility aliases
831
-
832
- Two thin wrappers kept for callers migrating from existing upload pipelines:
833
-
834
- ```ruby
835
- SafeImage.optimize_image!("image.jpg")
836
- SafeImage.optimize_image!("image.png", allow_lossy_png: true)
837
- SafeImage.convert_to_jpeg("upload.heic", "upload.jpg", quality: 85)
838
- ```
839
-
840
- `optimize_image!(path, allow_lossy_png: false, strip_metadata: true, quality: nil, strict: true)`
841
- forwards to `optimize`, with `allow_lossy_png:` mapping to `mode:`.
842
- `convert_to_jpeg(from, to, ...)` forwards to `convert` with `format: "jpg"`
843
- and accepts the same keywords.
730
+ attachment/sandbox handling for direct-open routes.
844
731
 
845
732
  ## Security
846
733
 
@@ -857,12 +744,12 @@ What it does:
857
744
  - starts external commands with an allowlisted environment, private temp/home/cache
858
745
  directories, bounded stdout/stderr, and process-group timeout cleanup
859
746
  - uses explicit libvips loaders selected from allowlisted extensions
860
- - enables libvips' untrusted-operation block in-process (deliberately
747
+ - enables libvips' untrusted-operation block inside the helper (deliberately
861
748
  re-enabling only the libjxl loader/saver, which libvips tags untrusted,
862
749
  because JPEG XL is part of the supported input surface)
863
- - blocks libvips ImageMagick loader classes in the libvips binding, which
864
- itself exposes only the operations the gem invokes
865
- - 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
866
753
  - strips metadata on generated images where applicable
867
754
  - rejects symlinked local input/output paths and symlinked path components for
868
755
  untrusted file-processing paths
@@ -879,12 +766,8 @@ What it does:
879
766
  blocking, DNS pinning, redirect limits, HTTPS-to-HTTP rejection, proxy-env
880
767
  bypass prevention, request-header allowlists, content-type/extension
881
768
  agreement, and probe-before-yield (details under [Remote URLs](#remote-urls))
882
- - 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
883
770
  ImageMagick for probing
884
- - sanitises SVG conservatively, allowlist based: rejects `DOCTYPE` and XML
885
- processing instructions, removes comments and disallowed elements, converts
886
- CDATA to escaped text, and blocks event handlers, external URLs, and
887
- `javascript:` / `data:` URL values
888
771
  - supports optional Landlock subprocess sandboxing on Linux
889
772
 
890
773
  The backend is a configuration decision, not a per-call option. Safe Image
@@ -907,23 +790,23 @@ ImageMagick path runs with:
907
790
 
908
791
  That does **not** make hostile files benign. Raster decoders still parse attacker
909
792
  controlled bytes: libjpeg, libpng, libwebp, libheif/HEIC/AVIF, ImageMagick's
910
- raster decoders, and libvips loaders. If one of those decoders has a memory
911
- corruption bug or pathological resource-consumption bug, policy alone is not a
912
- 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.
913
796
 
914
797
  So the intended posture is:
915
798
 
916
799
  - without Landlock: hardened, centralized image processing with the major
917
800
  ImageMagick delegate/pseudo-protocol foot-guns removed
918
- - with Landlock: the same hardening plus a real containment boundary around all
919
- public operations
801
+ - with Landlock: the same hardening plus a containment boundary around child
802
+ helpers/tools that parse or transform raster bytes
920
803
 
921
804
  Do not describe non-sandboxed operation as making hostile images safe. The honest
922
805
  claim is defense-in-depth, not immunity.
923
806
 
924
- ### Atomic Landlock sandboxing
807
+ ### Optional Landlock containment
925
808
 
926
- Landlock support is optional, but atomic once configured.
809
+ Landlock support is optional, but fail-closed once configured.
927
810
 
928
811
  ```ruby
929
812
  SafeImage.sandbox_available? # => true/false, works before configure!
@@ -931,74 +814,44 @@ SafeImage.configure!(backend: :vips, landlock: true) # raises if unavailable
931
814
  SafeImage.config.landlock # => true
932
815
  ```
933
816
 
934
- With `landlock: true`, every public operation routes through the sandbox
935
- worker:
936
-
937
- - `probe`
938
- - `type`
939
- - `size`
940
- - `dimensions`
941
- - `info`
942
- - `orientation`
943
- - `dominant_color`
944
- - `thumbnail`
945
- - `optimize`
946
- - `resize`
947
- - `crop`
948
- - `downsize`
949
- - `convert`
950
- - `convert_to_jpeg` compatibility alias
951
- - `fix_orientation`
952
- - `convert_favicon_to_png`
953
- - `frame_count`
954
- - `animated?`
955
- - `letter_avatar`
956
- - `optimize_image!`
957
- - `sanitize_svg!`
958
-
959
- There is no silent fallback once landlock is configured. If sandbox setup or
960
- a sandboxed command fails, the operation fails.
961
-
962
- The sandbox grants read/write access only to the paths inferred from the
963
- operation arguments, plus runtime/library paths and temporary directories needed
964
- by Ruby, libvips, ImageMagick, and optimizer tools. Worker processes inherit the
965
- parent's backend and pixel-ceiling configuration; landlock is forced off
966
- inside the worker so sandboxed operations never nest.
967
-
968
- Operations are served by a pool of resident **zygote** workers: each is a
969
- fresh Ruby process that boots the gem once and then forks a child per
970
- operation, so the ~85ms boot cost (Ruby + requires + libvips init) is paid
971
- once per burst instead of per call — a warm sandboxed operation costs ~3–8ms
972
- over the unsandboxed one. The pool grows on demand to
973
- `SAFE_IMAGE_ZYGOTE_WORKERS` (default 8), so N threads run N sandboxed
974
- operations concurrently (throughput scales near-linearly with cores until the
975
- work itself saturates the CPU); offered concurrency past the cap blocks until
976
- a worker frees, which also bounds how many libvips decodes run at once.
977
- Idling is cheap (~16MB private memory per worker, zero CPU), so a worker
978
- lingers for `Zygote::IDLE_SECONDS` (300) without work before exiting on its
979
- own; the next operation boots a new one. Workers also exit immediately when
980
- their parent process does, and `configure!` always retires the pool.
981
-
982
- A zygote itself never touches untrusted bytes: each forked child first
983
- applies rlimits, its per-operation Landlock policy (filesystem allowlist, all
984
- TCP denied on Landlock ABI ≥ 4, abstract-unix-socket/signal scopes on
985
- ABI ≥ 6), and — when the installed `landlock` gem exposes
986
- `seccomp_deny_network!` — the helper's deny-all-network seccomp filter (which
987
- blocks sockets of every family, closing the non-TCP/UDP gap the in-process
988
- Landlock policy alone leaves open), and only then runs the operation. Forking
989
- is sound because the zygote never runs operations itself — libvips is
990
- initialised but quiescent (no native threads) at every fork.
991
-
992
- `SAFE_IMAGE_ZYGOTE=0` falls back to the exec-per-operation worker (a fresh
993
- sandboxed Ruby per call through the Landlock helper binary, whose seccomp
994
- filter denies sockets of every family, no pool); `SAFE_IMAGE_ZYGOTE_WORKERS`
995
- and `SAFE_IMAGE_ZYGOTE_IDLE_SECONDS` tune the pool cap and idle window.
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.
996
849
 
997
850
  ## Development
998
851
 
999
852
  ```bash
1000
853
  bundle install
1001
- bundle exec rake # run the tests (nothing compiles)
854
+ bundle exec rake # run the tests (builds/uses the native vips helper)
1002
855
  docker/run.sh # run the suite on Debian bookworm's packaged libvips 8.14
1003
856
  bundle exec rubocop # lint
1004
857
  ```
@@ -1010,8 +863,8 @@ explanation when that support is missing.
1010
863
 
1011
864
  The suite includes golden-output checks, cross-backend parity checks (the
1012
865
  claim is operation parity, not byte-for-byte output identity across
1013
- ImageMagick/libvips versions), policy-denial checks, and a real-image atomic
1014
- sandbox sweep over the full public operation list.
866
+ ImageMagick/libvips versions), policy-denial checks, and Landlock sweeps over
867
+ helper/tool execution paths.
1015
868
 
1016
869
  Gem packaging uses the standard Bundler tasks (`rake build`, `rake install`).
1017
870
  Releases: bump `SafeImage::VERSION`, merge to `main`, and CI publishes the