safe_image 0.1.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.
data/README.md CHANGED
@@ -3,77 +3,36 @@
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, favicon
7
- conversion, and letter-avatar generation. The default fast path uses a tiny
8
- native extension that calls `libvips` directly. Compatibility paths use
9
- ImageMagick, but with shell-free command execution and a restrictive bundled
10
- policy.
6
+ cropping, converting, optimising, SVG sanitising, animation checks, dominant
7
+ colour extraction, favicon conversion, and letter-avatar generation.
8
+ Everything that varies by host is decided once, at boot, with a single
9
+ mandatory call:
11
10
 
12
- Safe Image started as a Discourse extraction: the public surface intentionally
13
- covers the image operations Discourse performs today. The useful part is more
14
- general: hostile image bytes are a lousy thing to spread across model callbacks,
15
- upload helpers, optimizer wrappers, and hand-built command strings.
16
-
17
- ## Security model
18
-
19
- Safe Image is not magic pixie dust. It is a deliberately small choke point.
20
-
21
- What it does:
11
+ ```ruby
12
+ SafeImage.configure!(backend: :vips, landlock: true)
13
+ ```
22
14
 
23
- - uses explicit argv arrays for external commands, never shell strings
24
- - starts external commands with an allowlisted environment, private temp/home/cache
25
- directories, bounded stdout/stderr, and process-group timeout cleanup
26
- - uses explicit libvips loaders selected from allowlisted extensions
27
- - enables libvips' untrusted-operation block in-process
28
- - blocks libvips ImageMagick loader classes in the native extension
29
- - disables libvips cache by default in-process
30
- - strips metadata on generated images where applicable
31
- - rejects symlinked local input/output paths and symlinked path components for
32
- untrusted file-processing paths
33
- - caps decoded pixels before expensive work: the libvips path enforces a
34
- default `SafeImage::DEFAULT_MAX_PIXELS` (128MP) ceiling even when no
35
- `max_pixels` is given, and callers can raise or lower it per call
36
- - ships a restrictive ImageMagick `policy.xml`
37
- - denies Ghostscript-backed formats and dangerous ImageMagick features:
38
- - `PS`, `PS2`, `PS3`, `EPS`, `EPSF`, `PDF`, `XPS`, `PCL`
39
- - `MSL`, `MVG`
40
- - `HTTP`, `HTTPS`, `URL`
41
- - delegates, filters, and `@file` indirection
42
- - supports optional Landlock subprocess sandboxing on Linux
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.
43
21
 
44
- The ImageMagick backend is explicit. Safe Image will not silently fall from the
45
- libvips path into generic ImageMagick decoding.
22
+ The premise is that hostile image bytes are a lousy thing to spread across
23
+ model callbacks, upload helpers, optimizer wrappers, and hand-built command
24
+ strings. Safe Image puts the risky operations behind one small, hardened
25
+ choke point instead.
46
26
 
47
27
  ## Install
48
28
 
49
- System dependency:
50
-
51
- - `libvips` headers and library, discoverable through `pkg-config vips`
52
-
53
- Optional command dependencies:
54
-
55
- - `magick` / `convert` and `identify` for ImageMagick compatibility operations
56
- - `jpegoptim` for JPEG optimisation
57
- - `oxipng` for PNG lossless optimisation
58
- - `pngquant` for optional lossy PNG optimisation
59
- - `cjpegli` for optional Jpegli JPEG encoding of generated JPEGs
60
-
61
- Ruby runtime dependency:
62
-
63
- - `rexml` for SVG sanitising
64
-
65
- Optional Ruby dependency:
66
-
67
- - `landlock` for Linux subprocess sandboxing
68
-
69
- `landlock` is intentionally **not** a gem dependency. Install it in the host
70
- application if you want sandboxing.
71
-
72
- `cjpegli` is also intentionally optional. On Arch it is provided by `libjxl`;
73
- on macOS it is commonly installed via `brew install jpeg-xl`; Debian/Ubuntu
74
- package names vary by release (`libjpegli-tools` where available). Safe Image
75
- detects it at runtime and falls back unless the caller explicitly requests
76
- `encoder: :cjpegli`.
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.
77
36
 
78
37
  ```bash
79
38
  gem build safe_image.gemspec
@@ -83,6 +42,8 @@ gem install ./safe_image-0.1.0.gem
83
42
  ```ruby
84
43
  require "safe_image"
85
44
 
45
+ SafeImage.configure!(backend: :vips, landlock: false)
46
+
86
47
  result = SafeImage.thumbnail(
87
48
  input: "upload.jpg",
88
49
  output: "thumb.jpg",
@@ -102,7 +63,144 @@ SafeImage.convert(
102
63
  )
103
64
  ```
104
65
 
105
- ## Return values
66
+ ## Configuration
67
+
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`.
71
+
72
+ ```ruby
73
+ SafeImage.configure!(
74
+ backend: :vips, # required: :vips or :imagemagick — decodes all untrusted bytes
75
+ landlock: true, # required: route every operation through the Landlock sandbox
76
+ max_pixels: SafeImage::DEFAULT_MAX_PIXELS # optional: default decompression-bomb ceiling (128MP)
77
+ )
78
+ ```
79
+
80
+ Validation is eager, so a misconfigured host fails at boot rather than on the
81
+ first request:
82
+
83
+ - `backend: :vips` dlopens libvips and raises if it is unavailable
84
+ - `backend: :imagemagick` raises if no `magick`/`convert` executable is found
85
+ - `landlock: true` raises if the Landlock sandbox is unavailable
86
+ (`SafeImage.sandbox_available?` works before `configure!`, so a host can
87
+ probe first)
88
+ - unknown values raise `ArgumentError`
89
+
90
+ Calling `configure!` again replaces the configuration atomically (last call
91
+ wins), so reloading initializers in development is safe. `SafeImage.config`
92
+ returns the current frozen configuration and `SafeImage.configured?` reports
93
+ whether one is set.
94
+
95
+ Per-call `max_pixels:` still overrides the configured ceiling for an
96
+ individual operation; everything else about backend selection and sandboxing
97
+ is decided here and only here.
98
+
99
+ Choosing a backend:
100
+
101
+ - `:vips` — the recommended fast path: explicit native loaders, in-process
102
+ hardening, no subprocess per operation
103
+ - `:imagemagick` — matches classic ImageMagick `convert` pipelines; also the
104
+ option for hosts without libvips. Formats are decoded by ImageMagick under
105
+ the bundled restrictive policy.
106
+
107
+ A format the configured backend cannot decode fails closed — e.g. ICO
108
+ transform inputs need `:imagemagick` (ICO *metadata* is parsed in pure Ruby on
109
+ either backend), and HEIC needs a libvips build with libheif on `:vips`.
110
+
111
+ ## Dependencies
112
+
113
+ ### Quick install
114
+
115
+ Debian / Ubuntu:
116
+
117
+ ```bash
118
+ sudo apt-get install --no-install-recommends \
119
+ libvips42 imagemagick jpegoptim pngquant oxipng libjpeg-turbo-progs
120
+ ```
121
+
122
+ `oxipng` is packaged from Debian 13 / Ubuntu 24.04; on older releases install
123
+ it with `cargo install oxipng`. For `cjpegli`, Debian/Ubuntu package names
124
+ vary by release (`libjpegli-tools` where available); it is optional and
125
+ detected at runtime.
126
+
127
+ Arch:
128
+
129
+ ```bash
130
+ sudo pacman -S --needed libvips \
131
+ imagemagick jpegoptim pngquant oxipng libjpeg-turbo libjxl
132
+ ```
133
+
134
+ (`libjpeg-turbo` provides `jpegtran`, `libjxl` provides `cjpegli`.)
135
+
136
+ ### What each dependency is for
137
+
138
+ | Dependency | Kind | Needed for | Without it |
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 |
141
+ | ImageMagick (`magick`/`convert`, `identify`) | required for `backend: :imagemagick` | every operation on the `:imagemagick` backend | `configure!(backend: :imagemagick)` raises at boot |
142
+ | `jpegoptim` | required for JPEG `optimize` | lossless JPEG optimisation and metadata stripping | JPEG `optimize` raises in strict mode |
143
+ | `oxipng` | required for PNG `optimize` | lossless PNG optimisation | PNG `optimize` raises in strict mode |
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) |
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 |
149
+
150
+ The `landlock` gem is intentionally **not** a gem dependency; add it to the
151
+ host application's Gemfile if you want sandboxing.
152
+
153
+ ### libvips build capabilities
154
+
155
+ Some features depend on how the host's libvips was built (all present in
156
+ stock Debian, Ubuntu and Arch packages):
157
+
158
+ - **libheif** — HEIC/AVIF decode (`probe`, `convert`, thumbnails)
159
+ - **Pango text** (`VipsText`) — `letter_avatar`; without it the operation
160
+ raises `UnsupportedFormatError` (configure `backend: :imagemagick` if you
161
+ need letter avatars on such a build)
162
+ - **cgif** (`gifsave`) — GIF *output* from the vips backend; GIF *decode*
163
+ (libnsgif) is always built in
164
+ - **libjxl** (`jxlload`/`jxlsave`) — JPEG XL decode and encode
165
+
166
+ Check a host with:
167
+
168
+ ```bash
169
+ vips -l | grep -cE "VipsForeignSaveCgif|VipsText|VipsForeignLoadHeif|VipsForeignLoadJxl" # expect 4+
170
+ ```
171
+
172
+ ### Fonts
173
+
174
+ Letter avatars need **no font packages** with the default `DejaVu-Sans`
175
+ token: the gem bundles DejaVu Sans and pins it via fontconfig's app-font API.
176
+ The other allowlisted tokens (`NimbusSans-Regular`, `Liberation-Sans`,
177
+ `Arial`, `Helvetica`, `Adwaita-Sans`) resolve through system fontconfig —
178
+ install e.g. `fonts-liberation` (Debian/Ubuntu) or `ttf-liberation` (Arch) if
179
+ you select them. Glyphs outside DejaVu's coverage (CJK, Hangul, ...) fall
180
+ back per-glyph to whatever system fonts exist.
181
+
182
+ ### Checking a host
183
+
184
+ These probes work before `configure!`, so an application can inspect the host
185
+ and then make its configuration decision:
186
+
187
+ ```ruby
188
+ require "safe_image"
189
+
190
+ %w[magick identify jpegoptim oxipng pngquant jpegtran cjpegli].each do |tool|
191
+ puts format("%-10s %s", tool, SafeImage::Runner.available?(tool) ? "ok" : "missing")
192
+ end
193
+ puts format("%-10s %s", "libvips", SafeImage::VipsGlue.available? ? "ok" : "unavailable")
194
+ puts format("%-10s %s", "sandbox", SafeImage.sandbox_available? ? "ok" : "unavailable")
195
+ ```
196
+
197
+ ## API
198
+
199
+ All operations are module functions on `SafeImage` and run on the configured
200
+ backend. Operations that decode an image accept `max_pixels:` to override the
201
+ configured decompression-bomb ceiling for that one call.
202
+
203
+ ### Return values
106
204
 
107
205
  Image-producing operations return `SafeImage::Result`:
108
206
 
@@ -116,7 +214,7 @@ SafeImage::Result[
116
214
  height:,
117
215
  filesize:,
118
216
  backend:, # e.g. "libvips-direct", "imagemagick", "cjpegli",
119
- # "libvips-direct+cjpegli", or "sandboxed-..."
217
+ # "libvips-direct+cjpegli"
120
218
  duration_ms:,
121
219
  optimizer: # optimizer tool list for thumbnail path, otherwise nil
122
220
  ]
@@ -130,23 +228,35 @@ Optimizer operations return a hash:
130
228
  before_bytes: 123_456,
131
229
  after_bytes: 120_000,
132
230
  saved_bytes: 3_456,
133
- tools: ["jpegoptim"]
231
+ tools: ["jpegoptim"],
232
+ rotated_from: nil, # the EXIF orientation baked into the pixels, when one was set
233
+ trimmed: false # true when uprighting dropped partial edge blocks (see optimize)
134
234
  }
135
235
  ```
136
236
 
137
- ## Core API
237
+ ### Probing and metadata
238
+
239
+ Metadata operations for local files. `type`, `size`/`dimensions`,
240
+ `orientation`, and `info` are intended to cover the local-file parts of APIs
241
+ like `FastImage.type`, `FastImage.size`, and `FastImage#orientation` without
242
+ adding a Ruby dependency. None of these fetch remote URLs — see
243
+ [Remote URLs](#remote-urls) for that.
138
244
 
139
- ### `SafeImage.probe(path, max_pixels: nil)`
245
+ #### `SafeImage.probe(path, max_pixels: nil)`
140
246
 
141
- Reads image metadata through the direct libvips backend.
247
+ Reads image metadata through the configured backend.
142
248
 
143
- Supported inputs:
249
+ Supported inputs on the `:vips` backend:
144
250
 
145
251
  - `jpg` / `jpeg`
146
252
  - `png`
253
+ - `gif` (first frame, via libvips' bundled libnsgif loader)
147
254
  - `webp`
148
255
  - `heic` / `heif`
149
256
  - `avif`
257
+ - `jxl` (requires a libvips build with libjxl support)
258
+ - `ico` (pure-Ruby directory parse on either backend; reports the largest
259
+ entry's dimensions)
150
260
 
151
261
  ```ruby
152
262
  info = SafeImage.probe("upload.jpg", max_pixels: 40_000_000)
@@ -155,72 +265,7 @@ puts "#{info.width}x#{info.height} #{info.input_format}"
155
265
 
156
266
  Raises `SafeImage::LimitError` if `width * height > max_pixels`.
157
267
 
158
- ### `SafeImage.thumbnail(...)`
159
-
160
- Creates a center-cropped thumbnail.
161
-
162
- ```ruby
163
- result = SafeImage.thumbnail(
164
- input: "upload.jpg",
165
- output: "thumb.jpg",
166
- width: 600,
167
- height: 400,
168
- format: nil, # inferred from output extension when nil
169
- quality: 85,
170
- max_pixels: 40_000_000,
171
- backend: :vips, # :vips or :imagemagick
172
- optimize: true,
173
- optimize_mode: :lossless, # :lossless or :lossy for PNG optimisation
174
- execution: :inline, # :inline, :sandbox, :sandbox_if_available
175
- encoder: :auto, # :auto, :cjpegli, :vips, :imagemagick for JPEG output
176
- chroma_subsampling: :auto # :auto, "420", "422", "444"
177
- )
178
- ```
179
-
180
- Supported outputs for the direct libvips backend:
181
-
182
- - `jpg` / `jpeg`
183
- - `png`
184
- - `webp`
185
- - `avif`
186
-
187
- `execution: :sandbox` is fail-closed: it raises if Landlock is unavailable.
188
- `execution: :sandbox_if_available` uses the sandbox only when available.
189
-
190
- ## JPEG encoder selection
191
-
192
- Safe Image separates **encoding generated JPEGs** from **optimising existing
193
- JPEGs**. This avoids hiding a lossy re-encode behind a method named `optimize`.
194
-
195
- | Operation | `encoder: :auto` behavior |
196
- | --- | --- |
197
- | `thumbnail` / `resize` / `crop` / `downsize` to JPEG with `backend: :vips` | use `cjpegli` when installed; otherwise use normal libvips JPEG output |
198
- | `convert("input.png", "output.jpg", format: "jpg")` | use `cjpegli` when installed; otherwise use ImageMagick compatibility path |
199
- | `convert` from HEIC/WebP/AVIF/GIF/JPEG to JPEG | fall back to ImageMagick compatibility path; `cjpegli` is not treated as a universal decoder |
200
- | `optimize("existing.jpg")` | use `jpegoptim`; never use `cjpegli` by default |
201
-
202
- Encoder controls:
203
-
204
- | Option | Meaning |
205
- | --- | --- |
206
- | `encoder: :auto` | best available default with safe fallback |
207
- | `encoder: :cjpegli` | require Jpegli and fail closed if unavailable/unsupported |
208
- | `encoder: :vips` | force normal libvips JPEG output where available |
209
- | `encoder: :imagemagick` | force ImageMagick compatibility output |
210
-
211
- `cjpegli` output is ordinary browser-compatible JPEG. It is optional because it
212
- is a system binary, not a Ruby dependency. Safe Image detects it at runtime.
213
-
214
- `chroma_subsampling: :auto` uses `4:4:4` for PNG-sourced JPEG conversion and
215
- `4:2:0` otherwise. Pass `"420"`, `"422"`, or `"444"` to force a value.
216
-
217
- ## Local metadata helpers
218
-
219
- These helpers are intended to cover the local-file parts of APIs like
220
- `FastImage.type`, `FastImage.size`, and `FastImage#orientation` without adding a
221
- Ruby dependency. They do not fetch remote URLs.
222
-
223
- ### `SafeImage.type(path, max_pixels: nil)`
268
+ #### `SafeImage.type(path, max_pixels: nil)`
224
269
 
225
270
  Returns a FastImage-style symbol for a local file:
226
271
 
@@ -233,7 +278,7 @@ SafeImage.type("icon.svg") # => :svg
233
278
  JPEG is returned as `:jpeg`, not `:jpg`, to match common Ruby image-probing
234
279
  conventions.
235
280
 
236
- ### `SafeImage.size(path, max_pixels: nil)` / `SafeImage.dimensions(path, max_pixels: nil)`
281
+ #### `SafeImage.size(path, max_pixels: nil)` / `SafeImage.dimensions(path, max_pixels: nil)`
237
282
 
238
283
  Returns `[width, height]` for a local file:
239
284
 
@@ -248,16 +293,24 @@ limited to local `.svg` files, caps input size/tree depth/element/attribute
248
293
  counts, rejects `DOCTYPE` and non-XML processing instructions, requires an `<svg>`
249
294
  root, and derives dimensions from numeric `width`/`height` or `viewBox`.
250
295
 
251
- ### `SafeImage.orientation(path, max_pixels: nil)`
296
+ #### `SafeImage.orientation(path, max_pixels: nil)`
252
297
 
253
- Returns the EXIF orientation integer for a local file, defaulting to `1` when no
254
- orientation is present or when ImageMagick cannot report it.
298
+ Returns the EXIF orientation integer (1-8) for a local file. On the `:vips`
299
+ backend it is read from the orientation header field the native loaders
300
+ populate during the header scan — no pixel data is decoded — and garbage tag
301
+ values clamp to `1`. On the `:imagemagick` backend it comes from `identify`.
302
+ SVG and ICO report `1` by definition.
303
+
304
+ Note one deliberate HEIC difference: libheif applies the container's
305
+ `irot`/`imir` transforms during decode, so the native path reports the
306
+ orientation of the pixels as actually decoded, rather than echoing a raw
307
+ EXIF tag that may already be baked in.
255
308
 
256
309
  ```ruby
257
310
  SafeImage.orientation("upload.jpg") # => 1
258
311
  ```
259
312
 
260
- ### `SafeImage.info(path, max_pixels: nil, animated: false, orientation: false)`
313
+ #### `SafeImage.info(path, max_pixels: nil, animated: false, orientation: false)`
261
314
 
262
315
  Returns a `SafeImage::Info` object for a local file:
263
316
 
@@ -274,12 +327,68 @@ info.orientation # => 1
274
327
  `animated:` and `orientation:` default to `false` because they may require extra
275
328
  ImageMagick work. When disabled, those fields are `nil`.
276
329
 
277
- ## Remote metadata helpers
330
+ #### `SafeImage.frame_count(path, max_pixels: nil)`
331
+
332
+ Returns the frame count. On the `:vips` backend it comes from the n-pages
333
+ header field via the native loaders — no pixel data is decoded; on the
334
+ `:imagemagick` backend from `identify`. ICO directories are counted by the
335
+ pure-Ruby parser on either backend.
336
+
337
+ ```ruby
338
+ frames = SafeImage.frame_count("animated.gif")
339
+ ```
340
+
341
+ #### `SafeImage.animated?(path, max_pixels: nil)`
342
+
343
+ Returns `true` when `frame_count(path) > 1`.
344
+
345
+ ```ruby
346
+ SafeImage.animated?("animated.webp")
347
+ ```
348
+
349
+ #### `SafeImage.dominant_color(path, max_pixels: nil)`
350
+
351
+ Computes the image's alpha-weighted average colour (first frame for animated
352
+ formats) and returns it as an uppercase `RRGGBB` hex string.
353
+
354
+ The `:vips` backend computes the exact per-channel mean natively, with ICO
355
+ decoded by the pure-Ruby parser. The `:imagemagick` backend uses a histogram
356
+ command. The two backends agree to within a few least-significant bits per
357
+ channel (ImageMagick averages through its resize filter rather than computing
358
+ the exact mean).
359
+
360
+ The pixel cap is enforced before the full decode on either backend,
361
+ undecodable input raises `InvalidImageError`, and SVG input raises
362
+ `UnsupportedFormatError`.
363
+
364
+ ```ruby
365
+ SafeImage.dominant_color("upload.png") # => "6F745E"
366
+ ```
367
+
368
+ ### Remote URLs
278
369
 
279
370
  These helpers are intended to cover `FastImage.size(url)` / `FastImage.type(url)`
280
371
  style use cases without another Ruby dependency. They use only Ruby stdlib
281
- `Net::HTTP`, download to a tempfile with a byte cap, then run the normal Safe
282
- Image local metadata path on that tempfile.
372
+ `Net::HTTP` and stream to a tempfile with a byte cap.
373
+
374
+ Like FastImage, the metadata helpers (`remote_size`, `remote_type`,
375
+ `remote_info`, `remote_animated?`) download as little as possible: the normal
376
+ Safe Image local metadata path probes the partially-downloaded tempfile as
377
+ bytes arrive (first at 64KB, then at growing thresholds) and the transfer is
378
+ aborted as soon as the answer is final — typically after the first 64KB.
379
+ Early answers are only trusted when more data cannot change them:
380
+
381
+ - "not animated" is reported only from the complete file, because a truncated
382
+ animation undercounts frames; "animated" is final as soon as a second frame
383
+ is seen
384
+ - SVG metadata always downloads the whole document, so the SVG parser's total
385
+ size cap keeps its meaning
386
+ - a probe failure on a prefix just means the download continues; a file that
387
+ never yields an early answer is downloaded and validated exactly like a
388
+ `fetch_remote` download
389
+
390
+ `fetch_remote` and `remote_dominant_color` need the complete body and always
391
+ download it (up to `max_bytes`).
283
392
 
284
393
  Remote fetching is deliberately conservative:
285
394
 
@@ -303,9 +412,14 @@ Remote fetching is deliberately conservative:
303
412
  - private, loopback, link-local, multicast, documentation, benchmarking,
304
413
  carrier-grade NAT, IPv4-mapped IPv6, NAT64, 6to4/Teredo, and other
305
414
  special-use resolved addresses are rejected by default
306
- - no image decoding happens directly from the socket
415
+ - no image decoding happens directly from the socket; probes only ever see the
416
+ on-disk tempfile
307
417
  - the final response `Content-Type` must be an allowed image type and must agree
308
- with an image-looking URL extension when one is present
418
+ with an image-looking URL extension when one is present — both are enforced
419
+ from the response headers, before any body bytes are downloaded
420
+ - the first bytes of the body must be compatible with the claimed format's
421
+ magic bytes (SVG, which has no signature, is exempt); an obviously mislabeled
422
+ body is dropped after the first chunk instead of being downloaded to the cap
309
423
  - downloaded content is probed before `fetch_remote` yields the tempfile, so the
310
424
  raw downloader cannot be used as a blind extension-based file saver
311
425
  - SVG remote metadata uses the same bounded SVG metadata parser after download;
@@ -316,7 +430,7 @@ or is intentionally probing a trusted internal URL. Passing `allow_private: true
316
430
  also permits non-standard ports; for public fetches, pass `allowed_ports:` if you
317
431
  really need to allow a different port.
318
432
 
319
- ### `SafeImage.remote_size(url, ...)` / `SafeImage.remote_dimensions(url, ...)`
433
+ #### `SafeImage.remote_size(url, ...)` / `SafeImage.remote_dimensions(url, ...)`
320
434
 
321
435
  ```ruby
322
436
  SafeImage.remote_size(
@@ -328,14 +442,14 @@ SafeImage.remote_size(
328
442
  # => [1600, 1200]
329
443
  ```
330
444
 
331
- ### `SafeImage.remote_type(url, ...)`
445
+ #### `SafeImage.remote_type(url, ...)`
332
446
 
333
447
  ```ruby
334
448
  SafeImage.remote_type("https://example.com/image.png", max_bytes: 10.megabytes)
335
449
  # => :png
336
450
  ```
337
451
 
338
- ### `SafeImage.remote_info(url, ...)`
452
+ #### `SafeImage.remote_info(url, ...)`
339
453
 
340
454
  ```ruby
341
455
  info = SafeImage.remote_info(
@@ -348,14 +462,21 @@ info.size # => [640, 480]
348
462
  info.animated # => true
349
463
  ```
350
464
 
351
- ### `SafeImage.remote_animated?(url, ...)`
465
+ #### `SafeImage.remote_animated?(url, ...)`
352
466
 
353
467
  ```ruby
354
468
  SafeImage.remote_animated?("https://example.com/image.webp", max_bytes: 10.megabytes)
355
469
  # => true / false
356
470
  ```
357
471
 
358
- ### `SafeImage.fetch_remote(url, ...) { |path| ... }`
472
+ #### `SafeImage.remote_dominant_color(url, ...)`
473
+
474
+ ```ruby
475
+ SafeImage.remote_dominant_color("https://example.com/image.png", max_bytes: 10.megabytes)
476
+ # => "6F745E"
477
+ ```
478
+
479
+ #### `SafeImage.fetch_remote(url, ...) { |path| ... }`
359
480
 
360
481
  Downloads a remote image to a tempfile and yields the local path:
361
482
 
@@ -365,121 +486,199 @@ SafeImage.fetch_remote("https://example.com/image.jpg", max_bytes: 10.megabytes)
365
486
  end
366
487
  ```
367
488
 
368
- When global Landlock is enabled, the network fetch itself is not put inside the
369
- Landlock worker because the worker denies network access. The downloaded tempfile
370
- is then passed through the normal Safe Image local image APIs, so decoding still
371
- uses the same sandboxed image-processing path.
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.
493
+
494
+ ### Generating images
495
+
496
+ Operations that write a new image. All of them run on the configured backend
497
+ and return [`SafeImage::Result`](#return-values).
498
+
499
+ #### `SafeImage.thumbnail(...)`
500
+
501
+ Creates a center-cropped thumbnail.
502
+
503
+ ```ruby
504
+ result = SafeImage.thumbnail(
505
+ input: "upload.jpg",
506
+ output: "thumb.jpg",
507
+ width: 600,
508
+ height: 400,
509
+ format: nil, # inferred from output extension when nil
510
+ quality: 85,
511
+ max_pixels: 40_000_000, # overrides the configured ceiling for this call
512
+ optimize: true,
513
+ optimize_mode: :lossless, # :lossless or :lossy for PNG optimisation
514
+ chroma_subsampling: :auto # :auto, "420", "422", "444" for JPEG output
515
+ )
516
+ ```
372
517
 
373
- ## Compatibility API
518
+ Supported outputs on the `:vips` backend:
374
519
 
375
- These methods are shaped around the image operations Discourse currently
376
- performs. They are useful outside Discourse too, but the names are deliberately
377
- boring because they map to common upload-pipeline tasks.
520
+ - `jpg` / `jpeg`
521
+ - `png`
522
+ - `gif` (requires a libvips build with cgif support; raises `UnsupportedFormatError` otherwise)
523
+ - `webp`
524
+ - `avif`
525
+ - `jxl` (requires a libvips build with libjxl support)
378
526
 
379
- ### `SafeImage.resize(from, to, width, height, quality: nil, backend: :imagemagick, optimize: true, max_pixels: nil, encoder: :auto, chroma_subsampling: :auto)`
527
+ #### `SafeImage.resize(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
380
528
 
381
529
  Creates a resized thumbnail-style output.
382
530
 
383
531
  ```ruby
384
532
  SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400)
385
- SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400, backend: :vips, quality: 85)
533
+ SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400, quality: 85)
386
534
  ```
387
535
 
388
- Backends:
536
+ `resize`, `crop` and `downsize` run on the configured backend:
389
537
 
390
- - `:imagemagick` default compatibility path
391
- - `:vips` direct libvips path
538
+ - `:vips` the direct libvips path; formats outside the native loader
539
+ allowlist (ICO input) fail closed
540
+ - `:imagemagick` — matches classic `convert` thumbnail pipelines
541
+ (`-thumbnail`, catrom interpolation, unsharp, sRGB profile); configure this
542
+ if byte-similar output with previously generated thumbnails matters
392
543
 
393
- ### `SafeImage.crop(from, to, width, height, quality: nil, backend: :imagemagick, optimize: true, max_pixels: nil, encoder: :auto, chroma_subsampling: :auto)`
544
+ #### `SafeImage.crop(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
394
545
 
395
- Creates a north-cropped image. This matches the avatar/optimized-image crop
396
- shape used by Discourse.
546
+ Creates a north-cropped image the shape typically used for square avatar
547
+ crops.
397
548
 
398
549
  ```ruby
399
550
  SafeImage.crop("upload.jpg", "avatar.jpg", 240, 240)
400
- SafeImage.crop("upload.jpg", "avatar.jpg", 240, 240, backend: :vips)
401
551
  ```
402
552
 
403
- ### `SafeImage.downsize(from, to, dimensions, backend: :imagemagick, optimize: true, max_pixels: nil, quality: 85, encoder: :auto, chroma_subsampling: :auto)`
553
+ #### `SafeImage.downsize(from, to, dimensions, optimize: true, max_pixels: nil, quality: 85, chroma_subsampling: :auto)`
404
554
 
405
555
  Downsizes an image using ImageMagick-style geometry strings.
406
556
 
407
557
  ```ruby
408
558
  SafeImage.downsize("large.png", "small.png", "50%")
409
- SafeImage.downsize("large.png", "small.png", "100x100>", backend: :vips)
410
- SafeImage.downsize("large.png", "small.png", "400000@", backend: :vips)
559
+ SafeImage.downsize("large.png", "small.png", "100x100>")
560
+ SafeImage.downsize("large.png", "small.png", "400000@")
411
561
  ```
412
562
 
413
- The direct vips backend supports the geometry forms covered by the test suite:
563
+ The vips backend supports the geometry forms covered by the test suite:
414
564
  percentage, bounding box with `>`, and pixel-area cap with `@`.
415
565
 
416
- ### `SafeImage.convert(from, to, format:, quality: nil, optimize: true, max_pixels: nil, encoder: :auto, chroma_subsampling: :auto)`
566
+ #### `SafeImage.convert(from, to, format:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
417
567
 
418
568
  Converts an input image to an explicit output `format:`. Unsupported formats
419
569
  raise `SafeImage::UnsupportedFormatError`.
420
570
 
421
- For JPEG output, `encoder: :auto` uses `cjpegli` when it is installed and the
422
- input can be encoded directly by Jpegli. Today that direct path is intentionally
423
- limited to PNG input; other formats fall back to the hardened ImageMagick
424
- compatibility backend. Use `encoder: :cjpegli` to require Jpegli and fail closed,
425
- or `encoder: :imagemagick` to force the compatibility path.
571
+ On the `:vips` backend this decodes through the native libvips loaders,
572
+ auto-orients, flattens transparency onto white for JPEG targets (matching the
573
+ ImageMagick path's `-background white -flatten`), and re-encodes. When no
574
+ `quality:` is given, native JPEG output uses quality 92 what ImageMagick
575
+ uses for sources without quality tables rather than libvips' default 75.
576
+ ICO input/output is outside the native loaders and fails closed (use
577
+ `convert_favicon_to_png` for favicons, or the `:imagemagick` backend).
578
+
579
+ For PNG-to-JPEG on the `:vips` backend, `cjpegli` is used automatically when
580
+ installed (see [JPEG encoding of generated images](#jpeg-encoding-of-generated-images)).
426
581
 
427
582
  ```ruby
428
583
  SafeImage.convert("upload.png", "upload.jpg", format: "jpg", quality: 85)
429
- SafeImage.convert("upload.png", "upload.jpg", format: "jpg", quality: 85, encoder: :cjpegli)
430
584
  SafeImage.convert("upload.heic", "upload.jpg", format: "jpg", quality: 85)
431
585
  SafeImage.convert("upload.jpg", "upload.webp", format: "webp", quality: 85)
432
586
  ```
433
587
 
434
- ### `SafeImage.fix_orientation(from, to = from, max_pixels: nil)`
588
+ #### `SafeImage.fix_orientation(from, to = from, max_pixels: nil, quality: nil)`
435
589
 
436
- Applies EXIF orientation through ImageMagick. If `to` is omitted, the file is
437
- rewritten in place.
590
+ Bakes the EXIF orientation into the pixels and clears the tag. The `:vips`
591
+ backend tries tiers in order:
438
592
 
439
- ```ruby
440
- SafeImage.fix_orientation("upload.jpg")
441
- SafeImage.fix_orientation("upload.jpg", "oriented.jpg")
442
- ```
593
+ 1. **jpegtran (lossless)** — for JPEGs with `jpegtran` installed, the
594
+ transform happens on the DCT coefficients with zero generation loss.
595
+ `-perfect` refuses non-MCU-aligned dimensions, in which case:
596
+ 2. **libvips re-encode** — autorotate and re-encode, stripping metadata.
597
+ JPEG output uses `quality:` (default 95).
443
598
 
444
- ### `SafeImage.convert_favicon_to_png(from, to, optimize: true, max_pixels: nil)`
599
+ The `:imagemagick` backend uses the previous `-auto-orient` behaviour and
600
+ re-encodes at the input's estimated quality.
445
601
 
446
- Extracts the largest ICO frame and writes PNG.
602
+ If `to` is omitted, the file is rewritten in place.
447
603
 
448
604
  ```ruby
449
- SafeImage.convert_favicon_to_png("favicon.ico", "favicon.png")
605
+ SafeImage.fix_orientation("upload.jpg")
606
+ SafeImage.fix_orientation("upload.jpg", "oriented.jpg")
450
607
  ```
451
608
 
452
- ### `SafeImage.frame_count(path, max_pixels: nil)`
609
+ #### `SafeImage.convert_favicon_to_png(from, to, optimize: true, max_pixels: nil)`
453
610
 
454
- Returns the frame count using the hardened ImageMagick identify path.
611
+ Extracts the largest ICO entry and writes PNG. On the `:vips` backend no
612
+ ImageMagick is involved: the container and legacy DIB payloads (1/4/8/24/32bpp
613
+ 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
615
+ payloads are re-encoded — never copied through verbatim — and their pixel cap
616
+ is enforced from the IHDR before any decoder runs. On the `:imagemagick`
617
+ backend the conversion runs through ImageMagick's ico decoder under the
618
+ bundled policy.
455
619
 
456
620
  ```ruby
457
- frames = SafeImage.frame_count("animated.gif")
621
+ SafeImage.convert_favicon_to_png("favicon.ico", "favicon.png")
458
622
  ```
459
623
 
460
- ### `SafeImage.animated?(path, max_pixels: nil)`
624
+ #### `SafeImage.letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")`
461
625
 
462
- Returns `true` when `frame_count(path) > 1`.
626
+ Generates a square letter avatar PNG: one grapheme blended in white at 80%
627
+ opacity over a solid background.
463
628
 
464
- ```ruby
465
- SafeImage.animated?("animated.webp")
466
- ```
629
+ The `:vips` backend renders natively through libvips' Pango text support (the
630
+ glyph is markup-escaped before rendering) and fails closed on builds without
631
+ a text renderer; the `:imagemagick` backend uses ImageMagick's annotation
632
+ path.
467
633
 
468
- ### `SafeImage.letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "NimbusSans-Regular")`
634
+ The default `DejaVu-Sans` font uses the DejaVu Sans file bundled with the gem
635
+ (see `lib/safe_image/fonts/DEJAVU-LICENSE`), so rendering does not depend on
636
+ which fonts the host has installed. The other allowlisted tokens
637
+ (`NimbusSans-Regular`, `Liberation-Sans`, `Arial`, `Helvetica`,
638
+ `Adwaita-Sans`) resolve through fontconfig.
469
639
 
470
- Generates a square letter avatar PNG.
640
+ The native path centres the glyph's ink box optically, which differs from the
641
+ ImageMagick path's baseline placement (where descenders could clip at the
642
+ canvas edge). Treat switching backends as a visual change: regenerate cached
643
+ avatars.
471
644
 
472
645
  ```ruby
473
646
  SafeImage.letter_avatar(
474
647
  output: "avatar.png",
475
648
  size: 360,
476
649
  background_rgb: [1, 2, 3],
477
- letter: "S",
478
- font: "Adwaita-Sans"
650
+ letter: "S"
479
651
  )
480
652
  ```
481
653
 
482
- ### `SafeImage.optimize(path, mode: :lossless, strip_metadata: true, quality: nil, strict: true)`
654
+ #### JPEG encoding of generated images
655
+
656
+ Safe Image separates **encoding generated JPEGs** from **optimising existing
657
+ JPEGs**. This avoids hiding a lossy re-encode behind a method named `optimize`.
658
+
659
+ Like the optimizer tools, the optional `cjpegli` encoder is availability
660
+ 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.
664
+
665
+ | Operation | Behavior |
666
+ | --- | --- |
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 |
670
+ | any operation on the `:imagemagick` backend | ImageMagick encodes; `cjpegli` is never used |
671
+ | `optimize("existing.jpg")` | use `jpegoptim`; never `cjpegli` |
672
+
673
+ `cjpegli` output is ordinary browser-compatible JPEG. It is optional because it
674
+ is a system binary, not a Ruby dependency. Safe Image detects it at runtime.
675
+
676
+ `chroma_subsampling: :auto` uses `4:4:4` for PNG-sourced JPEG conversion and
677
+ `4:2:0` otherwise. Pass `"420"`, `"422"`, or `"444"` to force a value.
678
+
679
+ ### Optimising in place
680
+
681
+ #### `SafeImage.optimize(path, mode: :lossless, strip_metadata: true, quality: nil, strict: true)`
483
682
 
484
683
  Optimises an existing JPEG or PNG in place.
485
684
 
@@ -494,6 +693,14 @@ JPEG path:
494
693
  - uses `jpegoptim`
495
694
  - `quality:` maps to `jpegoptim --max`
496
695
  - metadata is stripped unless `strip_metadata: false`
696
+ - an EXIF-oriented JPEG is uprighted first with jpegtran's lossless
697
+ transforms, because stripping would otherwise delete the orientation tag
698
+ without applying the rotation and ship the image sideways. MCU-aligned
699
+ images rotate exactly (`-perfect`); others drop the partial edge blocks
700
+ (`-trim`, under one MCU — at most 15px), reported as `trimmed: true` in
701
+ 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.
497
704
 
498
705
  PNG path:
499
706
 
@@ -504,56 +711,57 @@ PNG path:
504
711
  When `strict: true`, missing optimizer tools raise. When `strict: false`, missing
505
712
  optimizer tools are tolerated.
506
713
 
507
- ### `SafeImage.optimize_image!(path, allow_lossy_png: false, strip_metadata: true, quality: nil, strict: true)`
714
+ ### SVG sanitising
508
715
 
509
- Compatibility wrapper around `SafeImage.optimize`.
716
+ #### `SafeImage.sanitize_svg!(path, id_namespace:)`
510
717
 
511
- ```ruby
512
- SafeImage.optimize_image!("image.jpg")
513
- SafeImage.optimize_image!("image.png", allow_lossy_png: true)
514
- ```
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):
515
721
 
516
- ### `SafeImage.sanitize_svg!(path)`
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)
517
725
 
518
- Sanitises an SVG in place using a small REXML allowlist.
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}")
519
728
 
520
- ```ruby
521
- result = SafeImage.sanitize_svg!("icon.svg")
522
729
  puts result[:sanitized]
523
730
  ```
524
731
 
732
+ Omitting `id_namespace:` (or passing `nil`/`""`) raises `ArgumentError`.
733
+
525
734
  The sanitizer removes unsafe elements/attributes such as scripts and event
526
735
  handlers. It is intentionally conservative rather than a full browser-grade SVG
527
736
  implementation.
528
737
 
529
- ## Security posture
530
-
531
- Safe Image is a hardened boundary for untrusted image processing, not a magic
532
- wand. The goal is to centralize risky operations, make the safe path boring, and
533
- remove common image-processing foot-guns.
534
-
535
- Baseline hardening:
536
-
537
- - external commands use argv arrays, never shell strings
538
- - command environment, temp/home/cache directories, stdout/stderr size, and
539
- process-group timeout cleanup are controlled
540
- - libvips loaders are selected explicitly from an allowlist
541
- - libvips' untrusted-operation block is enabled in-process
542
- - libvips ImageMagick loader classes are blocked in the native extension
543
- - libvips cache is disabled by default in-process
544
- - local untrusted input/output paths reject symlinks and symlinked path components
545
- - generated images strip metadata where applicable
546
- - `max_pixels` checks fail before expensive work; the libvips path applies a
547
- default 128MP ceiling (`SafeImage::DEFAULT_MAX_PIXELS`) when none is supplied
548
- - remote fetch uses SSRF hardening: scheme/port restrictions, special-use IP
549
- blocking, DNS pinning, redirect limits, HTTPS-to-HTTP rejection, proxy-env
550
- bypass prevention, request-header allowlists, content-type/extension agreement,
551
- and probe-before-yield
552
- - SVG metadata uses a bounded parser; SVG is not handed to ImageMagick for probing
553
- - SVG sanitising is conservative and allowlist based; it rejects `DOCTYPE` and
554
- XML processing instructions, removes comments and disallowed elements, converts
555
- CDATA to escaped text, and blocks event handlers, external URLs, and
556
- `javascript:` / `data:` URL values
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.
557
765
 
558
766
  SVG sanitising is defense-in-depth for stored bytes. Applications that serve
559
767
  user-supplied SVGs directly should still use response-level controls such as a
@@ -562,19 +770,140 @@ attachment/sandbox handling for direct-open routes. Browsers restrict script
562
770
  execution when an SVG is embedded as `<img>`, but a top-level SVG document is a
563
771
  different sink.
564
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.
844
+
845
+ ## Security
846
+
847
+ Safe Image is not magic pixie dust. It is a deliberately small choke point:
848
+ the goal is to centralize risky operations, make the safe path boring, and
849
+ remove common image-processing foot-guns.
850
+
851
+ What it does:
852
+
853
+ - forces one explicit, eagerly validated `configure!` decision — which backend
854
+ decodes untrusted bytes, whether the Landlock sandbox is on — before any
855
+ operation runs; everything else raises `NotConfiguredError`
856
+ - uses explicit argv arrays for external commands, never shell strings
857
+ - starts external commands with an allowlisted environment, private temp/home/cache
858
+ directories, bounded stdout/stderr, and process-group timeout cleanup
859
+ - uses explicit libvips loaders selected from allowlisted extensions
860
+ - enables libvips' untrusted-operation block in-process (deliberately
861
+ re-enabling only the libjxl loader/saver, which libvips tags untrusted,
862
+ 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
866
+ - strips metadata on generated images where applicable
867
+ - rejects symlinked local input/output paths and symlinked path components for
868
+ untrusted file-processing paths
869
+ - caps decoded pixels before expensive work: the libvips path enforces a
870
+ default `SafeImage::DEFAULT_MAX_PIXELS` (128MP) ceiling even when no
871
+ `max_pixels` is given, and callers can raise or lower it per call
872
+ - ships a restrictive ImageMagick `policy.xml`
873
+ - denies Ghostscript-backed formats and dangerous ImageMagick features:
874
+ - `PS`, `PS2`, `PS3`, `EPS`, `EPSF`, `PDF`, `XPS`, `PCL`
875
+ - `MSL`, `MVG`
876
+ - `HTTP`, `HTTPS`, `URL`
877
+ - delegates, filters, and `@file` indirection
878
+ - hardens remote fetch against SSRF: scheme/port restrictions, special-use IP
879
+ blocking, DNS pinning, redirect limits, HTTPS-to-HTTP rejection, proxy-env
880
+ bypass prevention, request-header allowlists, content-type/extension
881
+ 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
883
+ 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
+ - supports optional Landlock subprocess sandboxing on Linux
889
+
890
+ The backend is a configuration decision, not a per-call option. Safe Image
891
+ will never silently fall from the libvips path into generic ImageMagick
892
+ decoding — a format the configured backend cannot decode fails closed with
893
+ `SafeImage::UnsupportedFormatError`.
894
+
565
895
  ### Security posture without Landlock
566
896
 
567
- Without Landlock, Safe Image still hardens the ImageMagick path substantially:
897
+ Without Landlock, everything above still applies; in particular the
898
+ ImageMagick path runs with:
568
899
 
569
- - delegates are disabled
570
- - filters are disabled
571
- - `@file` indirection is disabled
572
- - remote URL coders are disabled
573
- - Ghostscript/document/vector formats are denied
574
- - coders are deny-by-default with a small raster allowlist
575
- - commands are executed with argv arrays, not shell strings
576
- - command environment and ImageMagick policy path are controlled
577
- - ImageMagick resource limits are set in the bundled policy
900
+ - delegates disabled
901
+ - filters disabled
902
+ - `@file` indirection disabled
903
+ - remote URL coders disabled
904
+ - Ghostscript/document/vector formats denied
905
+ - coders deny-by-default with a small raster allowlist
906
+ - ImageMagick resource limits set in the bundled policy
578
907
 
579
908
  That does **not** make hostile files benign. Raster decoders still parse attacker
580
909
  controlled bytes: libjpeg, libpng, libwebp, libheif/HEIC/AVIF, ImageMagick's
@@ -592,18 +921,18 @@ So the intended posture is:
592
921
  Do not describe non-sandboxed operation as making hostile images safe. The honest
593
922
  claim is defense-in-depth, not immunity.
594
923
 
595
- ## Atomic Landlock sandboxing
924
+ ### Atomic Landlock sandboxing
596
925
 
597
- Landlock support is optional, but atomic once enabled.
926
+ Landlock support is optional, but atomic once configured.
598
927
 
599
928
  ```ruby
600
- SafeImage.sandbox_available? # => true/false
601
- SafeImage.enable_sandbox! # raises if unavailable
602
- SafeImage.sandbox_enabled? # => true
929
+ SafeImage.sandbox_available? # => true/false, works before configure!
930
+ SafeImage.configure!(backend: :vips, landlock: true) # raises if unavailable
931
+ SafeImage.config.landlock # => true
603
932
  ```
604
933
 
605
- After `SafeImage.enable_sandbox!`, every public operation routes through the
606
- sandbox worker:
934
+ With `landlock: true`, every public operation routes through the sandbox
935
+ worker:
607
936
 
608
937
  - `probe`
609
938
  - `type`
@@ -611,6 +940,7 @@ sandbox worker:
611
940
  - `dimensions`
612
941
  - `info`
613
942
  - `orientation`
943
+ - `dominant_color`
614
944
  - `thumbnail`
615
945
  - `optimize`
616
946
  - `resize`
@@ -626,39 +956,50 @@ sandbox worker:
626
956
  - `optimize_image!`
627
957
  - `sanitize_svg!`
628
958
 
629
- There is no silent fallback after global sandbox enablement. If sandbox setup or
959
+ There is no silent fallback once landlock is configured. If sandbox setup or
630
960
  a sandboxed command fails, the operation fails.
631
961
 
632
962
  The sandbox grants read/write access only to the paths inferred from the
633
963
  operation arguments, plus runtime/library paths and temporary directories needed
634
- by Ruby, libvips, ImageMagick, and optimizer tools. Network syscalls are denied
635
- through the Landlock helper's seccomp layer.
636
-
637
- ## Discourse parity
638
-
639
- Safe Image currently covers the image-operation surface Discourse performs in:
640
-
641
- - optimized image generation
642
- - upload preprocessing
643
- - thumbnail generation
644
- - avatar cropping / letter avatars
645
- - favicon conversion
646
- - HEIC/PNG-to-JPEG conversion
647
- - orientation fixing
648
- - animated image detection
649
- - JPEG/PNG optimisation
650
- - SVG sanitising
651
-
652
- The claim is operation parity, not byte-for-byte output identity across all
653
- ImageMagick/libvips versions. The test suite includes golden compatibility
654
- checks, ImageMagick parity checks, policy-denial checks, and a real-image atomic
655
- sandbox sweep over the full public operation list.
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.
656
996
 
657
997
  ## Development
658
998
 
659
999
  ```bash
660
1000
  bundle install
661
- bundle exec rake # compile the native extension and run the tests
1001
+ bundle exec rake # run the tests (nothing compiles)
1002
+ docker/run.sh # run the suite on Debian bookworm's packaged libvips 8.14
662
1003
  bundle exec rubocop # lint
663
1004
  ```
664
1005
 
@@ -667,10 +1008,14 @@ The minitest suite lives in `test/*_test.rb`; individual files run standalone
667
1008
  host support — cjpegli, HEIC delegates, the Landlock sandbox — skip with an
668
1009
  explanation when that support is missing.
669
1010
 
1011
+ The suite includes golden-output checks, cross-backend parity checks (the
1012
+ 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.
1015
+
670
1016
  Gem packaging uses the standard Bundler tasks (`rake build`, `rake install`).
671
- Releases follow the Discourse gem publication flow: bump
672
- `SafeImage::VERSION`, merge to `main`, and CI publishes the gem to
673
- RubyGems once the test matrix is green.
1017
+ Releases: bump `SafeImage::VERSION`, merge to `main`, and CI publishes the
1018
+ gem to RubyGems once the test matrix is green.
674
1019
 
675
1020
  ## Security reporting
676
1021
 
@@ -686,13 +1031,17 @@ Safe Image is MIT licensed.
686
1031
  The gem dynamically links to system `libvips`; `libvips` is
687
1032
  LGPL-2.1-or-later. Safe Image does not vendor `libvips`.
688
1033
 
1034
+ The gem bundles the DejaVu Sans font for deterministic letter-avatar
1035
+ rendering; its license (Bitstream Vera derivative, freely redistributable)
1036
+ ships alongside the font at `lib/safe_image/fonts/DEJAVU-LICENSE`.
1037
+
689
1038
  Optional command-line tools are discovered at runtime and executed as external
690
1039
  programs; they are not bundled into the gem. Typical licenses for those optional
691
1040
  tools are:
692
1041
 
693
1042
  | Tool | Purpose | Typical license |
694
1043
  | --- | --- | --- |
695
- | ImageMagick `magick` / `convert` / `identify` | compatibility operations | ImageMagick license |
1044
+ | ImageMagick `magick` / `convert` / `identify` | `:imagemagick` backend operations | ImageMagick license |
696
1045
  | `jpegoptim` | JPEG lossless optimisation / metadata stripping | GPL-2.0-or-later |
697
1046
  | `oxipng` | PNG lossless optimisation | MIT |
698
1047
  | `pngquant` | optional lossy PNG quantisation | GPL-3.0-or-later / ISC / BSD-2-Clause components |
@@ -700,4 +1049,4 @@ tools are:
700
1049
  | `heif-enc` / libheif tools | optional HEIC/AVIF tooling when installed | LGPL-3.0-or-later |
701
1050
 
702
1051
  Deployment packages may vary; check your distribution's package metadata if
703
- license compliance depends on the exact binary build.
1052
+ license compliance depends on the exact binary build.