safe_image 0.1.0 → 0.2.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
11
+ ```ruby
12
+ SafeImage.configure!(backend: :vips, landlock: true)
13
+ ```
18
14
 
19
- Safe Image is not magic pixie dust. It is a deliberately small choke point.
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.
20
21
 
21
- What it does:
22
-
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
43
-
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` for MCU-aligned JPEGs | falls back to the libvips re-encode tier |
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
  ]
@@ -134,19 +232,29 @@ Optimizer operations return a hash:
134
232
  }
135
233
  ```
136
234
 
137
- ## Core API
235
+ ### Probing and metadata
236
+
237
+ Metadata operations for local files. `type`, `size`/`dimensions`,
238
+ `orientation`, and `info` are intended to cover the local-file parts of APIs
239
+ like `FastImage.type`, `FastImage.size`, and `FastImage#orientation` without
240
+ adding a Ruby dependency. None of these fetch remote URLs — see
241
+ [Remote URLs](#remote-urls) for that.
138
242
 
139
- ### `SafeImage.probe(path, max_pixels: nil)`
243
+ #### `SafeImage.probe(path, max_pixels: nil)`
140
244
 
141
- Reads image metadata through the direct libvips backend.
245
+ Reads image metadata through the configured backend.
142
246
 
143
- Supported inputs:
247
+ Supported inputs on the `:vips` backend:
144
248
 
145
249
  - `jpg` / `jpeg`
146
250
  - `png`
251
+ - `gif` (first frame, via libvips' bundled libnsgif loader)
147
252
  - `webp`
148
253
  - `heic` / `heif`
149
254
  - `avif`
255
+ - `jxl` (requires a libvips build with libjxl support)
256
+ - `ico` (pure-Ruby directory parse on either backend; reports the largest
257
+ entry's dimensions)
150
258
 
151
259
  ```ruby
152
260
  info = SafeImage.probe("upload.jpg", max_pixels: 40_000_000)
@@ -155,72 +263,7 @@ puts "#{info.width}x#{info.height} #{info.input_format}"
155
263
 
156
264
  Raises `SafeImage::LimitError` if `width * height > max_pixels`.
157
265
 
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)`
266
+ #### `SafeImage.type(path, max_pixels: nil)`
224
267
 
225
268
  Returns a FastImage-style symbol for a local file:
226
269
 
@@ -233,7 +276,7 @@ SafeImage.type("icon.svg") # => :svg
233
276
  JPEG is returned as `:jpeg`, not `:jpg`, to match common Ruby image-probing
234
277
  conventions.
235
278
 
236
- ### `SafeImage.size(path, max_pixels: nil)` / `SafeImage.dimensions(path, max_pixels: nil)`
279
+ #### `SafeImage.size(path, max_pixels: nil)` / `SafeImage.dimensions(path, max_pixels: nil)`
237
280
 
238
281
  Returns `[width, height]` for a local file:
239
282
 
@@ -248,16 +291,24 @@ limited to local `.svg` files, caps input size/tree depth/element/attribute
248
291
  counts, rejects `DOCTYPE` and non-XML processing instructions, requires an `<svg>`
249
292
  root, and derives dimensions from numeric `width`/`height` or `viewBox`.
250
293
 
251
- ### `SafeImage.orientation(path, max_pixels: nil)`
294
+ #### `SafeImage.orientation(path, max_pixels: nil)`
295
+
296
+ Returns the EXIF orientation integer (1-8) for a local file. On the `:vips`
297
+ backend it is read from the orientation header field the native loaders
298
+ populate during the header scan — no pixel data is decoded — and garbage tag
299
+ values clamp to `1`. On the `:imagemagick` backend it comes from `identify`.
300
+ SVG and ICO report `1` by definition.
252
301
 
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.
302
+ Note one deliberate HEIC difference: libheif applies the container's
303
+ `irot`/`imir` transforms during decode, so the native path reports the
304
+ orientation of the pixels as actually decoded, rather than echoing a raw
305
+ EXIF tag that may already be baked in.
255
306
 
256
307
  ```ruby
257
308
  SafeImage.orientation("upload.jpg") # => 1
258
309
  ```
259
310
 
260
- ### `SafeImage.info(path, max_pixels: nil, animated: false, orientation: false)`
311
+ #### `SafeImage.info(path, max_pixels: nil, animated: false, orientation: false)`
261
312
 
262
313
  Returns a `SafeImage::Info` object for a local file:
263
314
 
@@ -274,7 +325,45 @@ info.orientation # => 1
274
325
  `animated:` and `orientation:` default to `false` because they may require extra
275
326
  ImageMagick work. When disabled, those fields are `nil`.
276
327
 
277
- ## Remote metadata helpers
328
+ #### `SafeImage.frame_count(path, max_pixels: nil)`
329
+
330
+ Returns the frame count. On the `:vips` backend it comes from the n-pages
331
+ header field via the native loaders — no pixel data is decoded; on the
332
+ `:imagemagick` backend from `identify`. ICO directories are counted by the
333
+ pure-Ruby parser on either backend.
334
+
335
+ ```ruby
336
+ frames = SafeImage.frame_count("animated.gif")
337
+ ```
338
+
339
+ #### `SafeImage.animated?(path, max_pixels: nil)`
340
+
341
+ Returns `true` when `frame_count(path) > 1`.
342
+
343
+ ```ruby
344
+ SafeImage.animated?("animated.webp")
345
+ ```
346
+
347
+ #### `SafeImage.dominant_color(path, max_pixels: nil)`
348
+
349
+ Computes the image's alpha-weighted average colour (first frame for animated
350
+ formats) and returns it as an uppercase `RRGGBB` hex string.
351
+
352
+ The `:vips` backend computes the exact per-channel mean natively, with ICO
353
+ decoded by the pure-Ruby parser. The `:imagemagick` backend uses a histogram
354
+ command. The two backends agree to within a few least-significant bits per
355
+ channel (ImageMagick averages through its resize filter rather than computing
356
+ the exact mean).
357
+
358
+ The pixel cap is enforced before the full decode on either backend,
359
+ undecodable input raises `InvalidImageError`, and SVG input raises
360
+ `UnsupportedFormatError`.
361
+
362
+ ```ruby
363
+ SafeImage.dominant_color("upload.png") # => "6F745E"
364
+ ```
365
+
366
+ ### Remote URLs
278
367
 
279
368
  These helpers are intended to cover `FastImage.size(url)` / `FastImage.type(url)`
280
369
  style use cases without another Ruby dependency. They use only Ruby stdlib
@@ -316,7 +405,7 @@ or is intentionally probing a trusted internal URL. Passing `allow_private: true
316
405
  also permits non-standard ports; for public fetches, pass `allowed_ports:` if you
317
406
  really need to allow a different port.
318
407
 
319
- ### `SafeImage.remote_size(url, ...)` / `SafeImage.remote_dimensions(url, ...)`
408
+ #### `SafeImage.remote_size(url, ...)` / `SafeImage.remote_dimensions(url, ...)`
320
409
 
321
410
  ```ruby
322
411
  SafeImage.remote_size(
@@ -328,14 +417,14 @@ SafeImage.remote_size(
328
417
  # => [1600, 1200]
329
418
  ```
330
419
 
331
- ### `SafeImage.remote_type(url, ...)`
420
+ #### `SafeImage.remote_type(url, ...)`
332
421
 
333
422
  ```ruby
334
423
  SafeImage.remote_type("https://example.com/image.png", max_bytes: 10.megabytes)
335
424
  # => :png
336
425
  ```
337
426
 
338
- ### `SafeImage.remote_info(url, ...)`
427
+ #### `SafeImage.remote_info(url, ...)`
339
428
 
340
429
  ```ruby
341
430
  info = SafeImage.remote_info(
@@ -348,14 +437,21 @@ info.size # => [640, 480]
348
437
  info.animated # => true
349
438
  ```
350
439
 
351
- ### `SafeImage.remote_animated?(url, ...)`
440
+ #### `SafeImage.remote_animated?(url, ...)`
352
441
 
353
442
  ```ruby
354
443
  SafeImage.remote_animated?("https://example.com/image.webp", max_bytes: 10.megabytes)
355
444
  # => true / false
356
445
  ```
357
446
 
358
- ### `SafeImage.fetch_remote(url, ...) { |path| ... }`
447
+ #### `SafeImage.remote_dominant_color(url, ...)`
448
+
449
+ ```ruby
450
+ SafeImage.remote_dominant_color("https://example.com/image.png", max_bytes: 10.megabytes)
451
+ # => "6F745E"
452
+ ```
453
+
454
+ #### `SafeImage.fetch_remote(url, ...) { |path| ... }`
359
455
 
360
456
  Downloads a remote image to a tempfile and yields the local path:
361
457
 
@@ -365,121 +461,199 @@ SafeImage.fetch_remote("https://example.com/image.jpg", max_bytes: 10.megabytes)
365
461
  end
366
462
  ```
367
463
 
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.
464
+ With `landlock: true` configured, the network fetch itself is not put inside
465
+ the Landlock worker because the worker denies network access. The downloaded
466
+ tempfile is then passed through the normal Safe Image local image APIs, so
467
+ decoding still uses the same sandboxed image-processing path.
468
+
469
+ ### Generating images
470
+
471
+ Operations that write a new image. All of them run on the configured backend
472
+ and return [`SafeImage::Result`](#return-values).
473
+
474
+ #### `SafeImage.thumbnail(...)`
475
+
476
+ Creates a center-cropped thumbnail.
477
+
478
+ ```ruby
479
+ result = SafeImage.thumbnail(
480
+ input: "upload.jpg",
481
+ output: "thumb.jpg",
482
+ width: 600,
483
+ height: 400,
484
+ format: nil, # inferred from output extension when nil
485
+ quality: 85,
486
+ max_pixels: 40_000_000, # overrides the configured ceiling for this call
487
+ optimize: true,
488
+ optimize_mode: :lossless, # :lossless or :lossy for PNG optimisation
489
+ chroma_subsampling: :auto # :auto, "420", "422", "444" for JPEG output
490
+ )
491
+ ```
372
492
 
373
- ## Compatibility API
493
+ Supported outputs on the `:vips` backend:
374
494
 
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.
495
+ - `jpg` / `jpeg`
496
+ - `png`
497
+ - `gif` (requires a libvips build with cgif support; raises `UnsupportedFormatError` otherwise)
498
+ - `webp`
499
+ - `avif`
500
+ - `jxl` (requires a libvips build with libjxl support)
378
501
 
379
- ### `SafeImage.resize(from, to, width, height, quality: nil, backend: :imagemagick, optimize: true, max_pixels: nil, encoder: :auto, chroma_subsampling: :auto)`
502
+ #### `SafeImage.resize(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
380
503
 
381
504
  Creates a resized thumbnail-style output.
382
505
 
383
506
  ```ruby
384
507
  SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400)
385
- SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400, backend: :vips, quality: 85)
508
+ SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400, quality: 85)
386
509
  ```
387
510
 
388
- Backends:
511
+ `resize`, `crop` and `downsize` run on the configured backend:
389
512
 
390
- - `:imagemagick` default compatibility path
391
- - `:vips` direct libvips path
513
+ - `:vips` the direct libvips path; formats outside the native loader
514
+ allowlist (ICO input) fail closed
515
+ - `:imagemagick` — matches classic `convert` thumbnail pipelines
516
+ (`-thumbnail`, catrom interpolation, unsharp, sRGB profile); configure this
517
+ if byte-similar output with previously generated thumbnails matters
392
518
 
393
- ### `SafeImage.crop(from, to, width, height, quality: nil, backend: :imagemagick, optimize: true, max_pixels: nil, encoder: :auto, chroma_subsampling: :auto)`
519
+ #### `SafeImage.crop(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
394
520
 
395
- Creates a north-cropped image. This matches the avatar/optimized-image crop
396
- shape used by Discourse.
521
+ Creates a north-cropped image the shape typically used for square avatar
522
+ crops.
397
523
 
398
524
  ```ruby
399
525
  SafeImage.crop("upload.jpg", "avatar.jpg", 240, 240)
400
- SafeImage.crop("upload.jpg", "avatar.jpg", 240, 240, backend: :vips)
401
526
  ```
402
527
 
403
- ### `SafeImage.downsize(from, to, dimensions, backend: :imagemagick, optimize: true, max_pixels: nil, quality: 85, encoder: :auto, chroma_subsampling: :auto)`
528
+ #### `SafeImage.downsize(from, to, dimensions, optimize: true, max_pixels: nil, quality: 85, chroma_subsampling: :auto)`
404
529
 
405
530
  Downsizes an image using ImageMagick-style geometry strings.
406
531
 
407
532
  ```ruby
408
533
  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)
534
+ SafeImage.downsize("large.png", "small.png", "100x100>")
535
+ SafeImage.downsize("large.png", "small.png", "400000@")
411
536
  ```
412
537
 
413
- The direct vips backend supports the geometry forms covered by the test suite:
538
+ The vips backend supports the geometry forms covered by the test suite:
414
539
  percentage, bounding box with `>`, and pixel-area cap with `@`.
415
540
 
416
- ### `SafeImage.convert(from, to, format:, quality: nil, optimize: true, max_pixels: nil, encoder: :auto, chroma_subsampling: :auto)`
541
+ #### `SafeImage.convert(from, to, format:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
417
542
 
418
543
  Converts an input image to an explicit output `format:`. Unsupported formats
419
544
  raise `SafeImage::UnsupportedFormatError`.
420
545
 
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.
546
+ On the `:vips` backend this decodes through the native libvips loaders,
547
+ auto-orients, flattens transparency onto white for JPEG targets (matching the
548
+ ImageMagick path's `-background white -flatten`), and re-encodes. When no
549
+ `quality:` is given, native JPEG output uses quality 92 what ImageMagick
550
+ uses for sources without quality tables rather than libvips' default 75.
551
+ ICO input/output is outside the native loaders and fails closed (use
552
+ `convert_favicon_to_png` for favicons, or the `:imagemagick` backend).
553
+
554
+ For PNG-to-JPEG on the `:vips` backend, `cjpegli` is used automatically when
555
+ installed (see [JPEG encoding of generated images](#jpeg-encoding-of-generated-images)).
426
556
 
427
557
  ```ruby
428
558
  SafeImage.convert("upload.png", "upload.jpg", format: "jpg", quality: 85)
429
- SafeImage.convert("upload.png", "upload.jpg", format: "jpg", quality: 85, encoder: :cjpegli)
430
559
  SafeImage.convert("upload.heic", "upload.jpg", format: "jpg", quality: 85)
431
560
  SafeImage.convert("upload.jpg", "upload.webp", format: "webp", quality: 85)
432
561
  ```
433
562
 
434
- ### `SafeImage.fix_orientation(from, to = from, max_pixels: nil)`
563
+ #### `SafeImage.fix_orientation(from, to = from, max_pixels: nil, quality: nil)`
435
564
 
436
- Applies EXIF orientation through ImageMagick. If `to` is omitted, the file is
437
- rewritten in place.
565
+ Bakes the EXIF orientation into the pixels and clears the tag. The `:vips`
566
+ backend tries tiers in order:
438
567
 
439
- ```ruby
440
- SafeImage.fix_orientation("upload.jpg")
441
- SafeImage.fix_orientation("upload.jpg", "oriented.jpg")
442
- ```
568
+ 1. **jpegtran (lossless)** — for JPEGs with `jpegtran` installed, the
569
+ transform happens on the DCT coefficients with zero generation loss.
570
+ `-perfect` refuses non-MCU-aligned dimensions, in which case:
571
+ 2. **libvips re-encode** — autorotate and re-encode, stripping metadata.
572
+ JPEG output uses `quality:` (default 95).
443
573
 
444
- ### `SafeImage.convert_favicon_to_png(from, to, optimize: true, max_pixels: nil)`
574
+ The `:imagemagick` backend uses the previous `-auto-orient` behaviour and
575
+ re-encodes at the input's estimated quality.
445
576
 
446
- Extracts the largest ICO frame and writes PNG.
577
+ If `to` is omitted, the file is rewritten in place.
447
578
 
448
579
  ```ruby
449
- SafeImage.convert_favicon_to_png("favicon.ico", "favicon.png")
580
+ SafeImage.fix_orientation("upload.jpg")
581
+ SafeImage.fix_orientation("upload.jpg", "oriented.jpg")
450
582
  ```
451
583
 
452
- ### `SafeImage.frame_count(path, max_pixels: nil)`
584
+ #### `SafeImage.convert_favicon_to_png(from, to, optimize: true, max_pixels: nil)`
453
585
 
454
- Returns the frame count using the hardened ImageMagick identify path.
586
+ Extracts the largest ICO entry and writes PNG. On the `:vips` backend no
587
+ ImageMagick is involved: the container and legacy DIB payloads (1/4/8/24/32bpp
588
+ BI_RGB plus the AND mask) are parsed in pure Ruby with explicit bounds checks,
589
+ and pixels are encoded through the hardened native libvips path. Embedded PNG
590
+ payloads are re-encoded — never copied through verbatim — and their pixel cap
591
+ is enforced from the IHDR before any decoder runs. On the `:imagemagick`
592
+ backend the conversion runs through ImageMagick's ico decoder under the
593
+ bundled policy.
455
594
 
456
595
  ```ruby
457
- frames = SafeImage.frame_count("animated.gif")
596
+ SafeImage.convert_favicon_to_png("favicon.ico", "favicon.png")
458
597
  ```
459
598
 
460
- ### `SafeImage.animated?(path, max_pixels: nil)`
599
+ #### `SafeImage.letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")`
461
600
 
462
- Returns `true` when `frame_count(path) > 1`.
601
+ Generates a square letter avatar PNG: one grapheme blended in white at 80%
602
+ opacity over a solid background.
463
603
 
464
- ```ruby
465
- SafeImage.animated?("animated.webp")
466
- ```
604
+ The `:vips` backend renders natively through libvips' Pango text support (the
605
+ glyph is markup-escaped before rendering) and fails closed on builds without
606
+ a text renderer; the `:imagemagick` backend uses ImageMagick's annotation
607
+ path.
467
608
 
468
- ### `SafeImage.letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "NimbusSans-Regular")`
609
+ The default `DejaVu-Sans` font uses the DejaVu Sans file bundled with the gem
610
+ (see `lib/safe_image/fonts/DEJAVU-LICENSE`), so rendering does not depend on
611
+ which fonts the host has installed. The other allowlisted tokens
612
+ (`NimbusSans-Regular`, `Liberation-Sans`, `Arial`, `Helvetica`,
613
+ `Adwaita-Sans`) resolve through fontconfig.
469
614
 
470
- Generates a square letter avatar PNG.
615
+ The native path centres the glyph's ink box optically, which differs from the
616
+ ImageMagick path's baseline placement (where descenders could clip at the
617
+ canvas edge). Treat switching backends as a visual change: regenerate cached
618
+ avatars.
471
619
 
472
620
  ```ruby
473
621
  SafeImage.letter_avatar(
474
622
  output: "avatar.png",
475
623
  size: 360,
476
624
  background_rgb: [1, 2, 3],
477
- letter: "S",
478
- font: "Adwaita-Sans"
625
+ letter: "S"
479
626
  )
480
627
  ```
481
628
 
482
- ### `SafeImage.optimize(path, mode: :lossless, strip_metadata: true, quality: nil, strict: true)`
629
+ #### JPEG encoding of generated images
630
+
631
+ Safe Image separates **encoding generated JPEGs** from **optimising existing
632
+ JPEGs**. This avoids hiding a lossy re-encode behind a method named `optimize`.
633
+
634
+ Like the optimizer tools, the optional `cjpegli` encoder is availability
635
+ driven: installed means used, absent means the configured backend encodes.
636
+ There is no encoder knob — cjpegli only ever encodes pixels Safe Image has
637
+ already decoded, so it is not part of the untrusted-input surface the backend
638
+ choice controls.
639
+
640
+ | Operation | Behavior |
641
+ | --- | --- |
642
+ | `thumbnail` / `resize` / `crop` / `downsize` to JPEG on the `:vips` backend | use `cjpegli` when installed; otherwise normal libvips JPEG output |
643
+ | `convert("input.png", "output.jpg", format: "jpg")` on the `:vips` backend | use `cjpegli` when installed (PNG is the one input Jpegli encodes directly); otherwise libvips |
644
+ | `convert` from HEIC/WebP/AVIF/GIF/JPEG to JPEG | decode through the native libvips loaders and encode with libvips; `cjpegli` is not treated as a universal decoder |
645
+ | any operation on the `:imagemagick` backend | ImageMagick encodes; `cjpegli` is never used |
646
+ | `optimize("existing.jpg")` | use `jpegoptim`; never `cjpegli` |
647
+
648
+ `cjpegli` output is ordinary browser-compatible JPEG. It is optional because it
649
+ is a system binary, not a Ruby dependency. Safe Image detects it at runtime.
650
+
651
+ `chroma_subsampling: :auto` uses `4:4:4` for PNG-sourced JPEG conversion and
652
+ `4:2:0` otherwise. Pass `"420"`, `"422"`, or `"444"` to force a value.
653
+
654
+ ### Optimising in place
655
+
656
+ #### `SafeImage.optimize(path, mode: :lossless, strip_metadata: true, quality: nil, strict: true)`
483
657
 
484
658
  Optimises an existing JPEG or PNG in place.
485
659
 
@@ -504,16 +678,9 @@ PNG path:
504
678
  When `strict: true`, missing optimizer tools raise. When `strict: false`, missing
505
679
  optimizer tools are tolerated.
506
680
 
507
- ### `SafeImage.optimize_image!(path, allow_lossy_png: false, strip_metadata: true, quality: nil, strict: true)`
508
-
509
- Compatibility wrapper around `SafeImage.optimize`.
681
+ ### SVG sanitising
510
682
 
511
- ```ruby
512
- SafeImage.optimize_image!("image.jpg")
513
- SafeImage.optimize_image!("image.png", allow_lossy_png: true)
514
- ```
515
-
516
- ### `SafeImage.sanitize_svg!(path)`
683
+ #### `SafeImage.sanitize_svg!(path)`
517
684
 
518
685
  Sanitises an SVG in place using a small REXML allowlist.
519
686
 
@@ -526,55 +693,90 @@ The sanitizer removes unsafe elements/attributes such as scripts and event
526
693
  handlers. It is intentionally conservative rather than a full browser-grade SVG
527
694
  implementation.
528
695
 
529
- ## Security posture
696
+ SVG sanitising is defense-in-depth for stored bytes. Applications that serve
697
+ user-supplied SVGs directly should still use response-level controls such as a
698
+ restrictive `Content-Security-Policy`, `X-Content-Type-Options: nosniff`, and/or
699
+ attachment/sandbox handling for direct-open routes. Browsers restrict script
700
+ execution when an SVG is embedded as `<img>`, but a top-level SVG document is a
701
+ different sink.
702
+
703
+ ### Compatibility aliases
704
+
705
+ Two thin wrappers kept for callers migrating from existing upload pipelines:
706
+
707
+ ```ruby
708
+ SafeImage.optimize_image!("image.jpg")
709
+ SafeImage.optimize_image!("image.png", allow_lossy_png: true)
710
+ SafeImage.convert_to_jpeg("upload.heic", "upload.jpg", quality: 85)
711
+ ```
712
+
713
+ `optimize_image!(path, allow_lossy_png: false, strip_metadata: true, quality: nil, strict: true)`
714
+ forwards to `optimize`, with `allow_lossy_png:` mapping to `mode:`.
715
+ `convert_to_jpeg(from, to, ...)` forwards to `convert` with `format: "jpg"`
716
+ and accepts the same keywords.
530
717
 
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
718
+ ## Security
719
+
720
+ Safe Image is not magic pixie dust. It is a deliberately small choke point:
721
+ the goal is to centralize risky operations, make the safe path boring, and
533
722
  remove common image-processing foot-guns.
534
723
 
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
724
+ What it does:
725
+
726
+ - forces one explicit, eagerly validated `configure!` decision — which backend
727
+ decodes untrusted bytes, whether the Landlock sandbox is on — before any
728
+ operation runs; everything else raises `NotConfiguredError`
729
+ - uses explicit argv arrays for external commands, never shell strings
730
+ - starts external commands with an allowlisted environment, private temp/home/cache
731
+ directories, bounded stdout/stderr, and process-group timeout cleanup
732
+ - uses explicit libvips loaders selected from allowlisted extensions
733
+ - enables libvips' untrusted-operation block in-process (deliberately
734
+ re-enabling only the libjxl loader/saver, which libvips tags untrusted,
735
+ because JPEG XL is part of the supported input surface)
736
+ - blocks libvips ImageMagick loader classes in the libvips binding, which
737
+ itself exposes only the operations the gem invokes
738
+ - disables libvips cache by default in-process
739
+ - strips metadata on generated images where applicable
740
+ - rejects symlinked local input/output paths and symlinked path components for
741
+ untrusted file-processing paths
742
+ - caps decoded pixels before expensive work: the libvips path enforces a
743
+ default `SafeImage::DEFAULT_MAX_PIXELS` (128MP) ceiling even when no
744
+ `max_pixels` is given, and callers can raise or lower it per call
745
+ - ships a restrictive ImageMagick `policy.xml`
746
+ - denies Ghostscript-backed formats and dangerous ImageMagick features:
747
+ - `PS`, `PS2`, `PS3`, `EPS`, `EPSF`, `PDF`, `XPS`, `PCL`
748
+ - `MSL`, `MVG`
749
+ - `HTTP`, `HTTPS`, `URL`
750
+ - delegates, filters, and `@file` indirection
751
+ - hardens remote fetch against SSRF: scheme/port restrictions, special-use IP
549
752
  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
753
+ bypass prevention, request-header allowlists, content-type/extension
754
+ agreement, and probe-before-yield (details under [Remote URLs](#remote-urls))
755
+ - parses SVG metadata with a bounded pure-Ruby parser; SVG is never handed to
756
+ ImageMagick for probing
757
+ - sanitises SVG conservatively, allowlist based: rejects `DOCTYPE` and XML
758
+ processing instructions, removes comments and disallowed elements, converts
555
759
  CDATA to escaped text, and blocks event handlers, external URLs, and
556
760
  `javascript:` / `data:` URL values
761
+ - supports optional Landlock subprocess sandboxing on Linux
557
762
 
558
- SVG sanitising is defense-in-depth for stored bytes. Applications that serve
559
- user-supplied SVGs directly should still use response-level controls such as a
560
- restrictive `Content-Security-Policy`, `X-Content-Type-Options: nosniff`, and/or
561
- attachment/sandbox handling for direct-open routes. Browsers restrict script
562
- execution when an SVG is embedded as `<img>`, but a top-level SVG document is a
563
- different sink.
763
+ The backend is a configuration decision, not a per-call option. Safe Image
764
+ will never silently fall from the libvips path into generic ImageMagick
765
+ decoding a format the configured backend cannot decode fails closed with
766
+ `SafeImage::UnsupportedFormatError`.
564
767
 
565
768
  ### Security posture without Landlock
566
769
 
567
- Without Landlock, Safe Image still hardens the ImageMagick path substantially:
770
+ Without Landlock, everything above still applies; in particular the
771
+ ImageMagick path runs with:
568
772
 
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
773
+ - delegates disabled
774
+ - filters disabled
775
+ - `@file` indirection disabled
776
+ - remote URL coders disabled
777
+ - Ghostscript/document/vector formats denied
778
+ - coders deny-by-default with a small raster allowlist
779
+ - ImageMagick resource limits set in the bundled policy
578
780
 
579
781
  That does **not** make hostile files benign. Raster decoders still parse attacker
580
782
  controlled bytes: libjpeg, libpng, libwebp, libheif/HEIC/AVIF, ImageMagick's
@@ -592,18 +794,18 @@ So the intended posture is:
592
794
  Do not describe non-sandboxed operation as making hostile images safe. The honest
593
795
  claim is defense-in-depth, not immunity.
594
796
 
595
- ## Atomic Landlock sandboxing
797
+ ### Atomic Landlock sandboxing
596
798
 
597
- Landlock support is optional, but atomic once enabled.
799
+ Landlock support is optional, but atomic once configured.
598
800
 
599
801
  ```ruby
600
- SafeImage.sandbox_available? # => true/false
601
- SafeImage.enable_sandbox! # raises if unavailable
602
- SafeImage.sandbox_enabled? # => true
802
+ SafeImage.sandbox_available? # => true/false, works before configure!
803
+ SafeImage.configure!(backend: :vips, landlock: true) # raises if unavailable
804
+ SafeImage.config.landlock # => true
603
805
  ```
604
806
 
605
- After `SafeImage.enable_sandbox!`, every public operation routes through the
606
- sandbox worker:
807
+ With `landlock: true`, every public operation routes through the sandbox
808
+ worker:
607
809
 
608
810
  - `probe`
609
811
  - `type`
@@ -611,6 +813,7 @@ sandbox worker:
611
813
  - `dimensions`
612
814
  - `info`
613
815
  - `orientation`
816
+ - `dominant_color`
614
817
  - `thumbnail`
615
818
  - `optimize`
616
819
  - `resize`
@@ -626,39 +829,22 @@ sandbox worker:
626
829
  - `optimize_image!`
627
830
  - `sanitize_svg!`
628
831
 
629
- There is no silent fallback after global sandbox enablement. If sandbox setup or
832
+ There is no silent fallback once landlock is configured. If sandbox setup or
630
833
  a sandboxed command fails, the operation fails.
631
834
 
632
835
  The sandbox grants read/write access only to the paths inferred from the
633
836
  operation arguments, plus runtime/library paths and temporary directories needed
634
837
  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.
838
+ through the Landlock helper's seccomp layer. Worker processes inherit the
839
+ parent's backend and pixel-ceiling configuration; landlock is forced off
840
+ inside the worker so sandboxed operations never nest.
656
841
 
657
842
  ## Development
658
843
 
659
844
  ```bash
660
845
  bundle install
661
- bundle exec rake # compile the native extension and run the tests
846
+ bundle exec rake # run the tests (nothing compiles)
847
+ docker/run.sh # run the suite on Debian bookworm's packaged libvips 8.14
662
848
  bundle exec rubocop # lint
663
849
  ```
664
850
 
@@ -667,10 +853,14 @@ The minitest suite lives in `test/*_test.rb`; individual files run standalone
667
853
  host support — cjpegli, HEIC delegates, the Landlock sandbox — skip with an
668
854
  explanation when that support is missing.
669
855
 
856
+ The suite includes golden-output checks, cross-backend parity checks (the
857
+ claim is operation parity, not byte-for-byte output identity across
858
+ ImageMagick/libvips versions), policy-denial checks, and a real-image atomic
859
+ sandbox sweep over the full public operation list.
860
+
670
861
  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.
862
+ Releases: bump `SafeImage::VERSION`, merge to `main`, and CI publishes the
863
+ gem to RubyGems once the test matrix is green.
674
864
 
675
865
  ## Security reporting
676
866
 
@@ -686,13 +876,17 @@ Safe Image is MIT licensed.
686
876
  The gem dynamically links to system `libvips`; `libvips` is
687
877
  LGPL-2.1-or-later. Safe Image does not vendor `libvips`.
688
878
 
879
+ The gem bundles the DejaVu Sans font for deterministic letter-avatar
880
+ rendering; its license (Bitstream Vera derivative, freely redistributable)
881
+ ships alongside the font at `lib/safe_image/fonts/DEJAVU-LICENSE`.
882
+
689
883
  Optional command-line tools are discovered at runtime and executed as external
690
884
  programs; they are not bundled into the gem. Typical licenses for those optional
691
885
  tools are:
692
886
 
693
887
  | Tool | Purpose | Typical license |
694
888
  | --- | --- | --- |
695
- | ImageMagick `magick` / `convert` / `identify` | compatibility operations | ImageMagick license |
889
+ | ImageMagick `magick` / `convert` / `identify` | `:imagemagick` backend operations | ImageMagick license |
696
890
  | `jpegoptim` | JPEG lossless optimisation / metadata stripping | GPL-2.0-or-later |
697
891
  | `oxipng` | PNG lossless optimisation | MIT |
698
892
  | `pngquant` | optional lossy PNG quantisation | GPL-3.0-or-later / ISC / BSD-2-Clause components |
@@ -700,4 +894,4 @@ tools are:
700
894
  | `heif-enc` / libheif tools | optional HEIC/AVIF tooling when installed | LGPL-3.0-or-later |
701
895
 
702
896
  Deployment packages may vary; check your distribution's package metadata if
703
- license compliance depends on the exact binary build.
897
+ license compliance depends on the exact binary build.