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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +351 -0
- data/README.md +637 -288
- data/SECURITY.md +27 -7
- data/lib/safe_image/discourse_compat.rb +256 -98
- data/lib/safe_image/fonts/DEJAVU-LICENSE +187 -0
- data/lib/safe_image/fonts/DejaVuSans.ttf +0 -0
- data/lib/safe_image/ico.rb +286 -0
- data/lib/safe_image/image_magick_backend.rb +39 -3
- data/lib/safe_image/imagemagick_policy/policy.xml +8 -1
- data/lib/safe_image/jpegli_backend.rb +3 -1
- data/lib/safe_image/native.rb +380 -1
- data/lib/safe_image/optimizer.rb +79 -4
- data/lib/safe_image/processor.rb +24 -70
- data/lib/safe_image/remote.rb +188 -10
- data/lib/safe_image/runner.rb +10 -2
- data/lib/safe_image/sandbox.rb +82 -29
- data/lib/safe_image/svg_css.rb +314 -0
- data/lib/safe_image/svg_metadata.rb +183 -27
- data/lib/safe_image/svg_sanitizer.rb +524 -43
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/vips_backend.rb +57 -0
- data/lib/safe_image/vips_glue.rb +361 -0
- data/lib/safe_image/zygote.rb +619 -0
- data/lib/safe_image.rb +154 -35
- metadata +47 -15
- data/ext/safe_image_native/extconf.rb +0 -8
- data/ext/safe_image_native/safe_image_native.c +0 -392
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,
|
|
7
|
-
conversion, and letter-avatar generation.
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
##
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
245
|
+
#### `SafeImage.probe(path, max_pixels: nil)`
|
|
140
246
|
|
|
141
|
-
Reads image metadata through the
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
+
#### `SafeImage.orientation(path, max_pixels: nil)`
|
|
252
297
|
|
|
253
|
-
Returns the EXIF orientation integer for a local file
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
Landlock worker because the worker denies network access. The downloaded
|
|
370
|
-
is then passed through the normal Safe Image local image APIs, so
|
|
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
|
-
|
|
518
|
+
Supported outputs on the `:vips` backend:
|
|
374
519
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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,
|
|
533
|
+
SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400, quality: 85)
|
|
386
534
|
```
|
|
387
535
|
|
|
388
|
-
|
|
536
|
+
`resize`, `crop` and `downsize` run on the configured backend:
|
|
389
537
|
|
|
390
|
-
- `:
|
|
391
|
-
|
|
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
|
-
|
|
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
|
|
396
|
-
|
|
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
|
-
|
|
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>"
|
|
410
|
-
SafeImage.downsize("large.png", "small.png", "400000@"
|
|
559
|
+
SafeImage.downsize("large.png", "small.png", "100x100>")
|
|
560
|
+
SafeImage.downsize("large.png", "small.png", "400000@")
|
|
411
561
|
```
|
|
412
562
|
|
|
413
|
-
The
|
|
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
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
588
|
+
#### `SafeImage.fix_orientation(from, to = from, max_pixels: nil, quality: nil)`
|
|
435
589
|
|
|
436
|
-
|
|
437
|
-
|
|
590
|
+
Bakes the EXIF orientation into the pixels and clears the tag. The `:vips`
|
|
591
|
+
backend tries tiers in order:
|
|
438
592
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
599
|
+
The `:imagemagick` backend uses the previous `-auto-orient` behaviour and
|
|
600
|
+
re-encodes at the input's estimated quality.
|
|
445
601
|
|
|
446
|
-
|
|
602
|
+
If `to` is omitted, the file is rewritten in place.
|
|
447
603
|
|
|
448
604
|
```ruby
|
|
449
|
-
SafeImage.
|
|
605
|
+
SafeImage.fix_orientation("upload.jpg")
|
|
606
|
+
SafeImage.fix_orientation("upload.jpg", "oriented.jpg")
|
|
450
607
|
```
|
|
451
608
|
|
|
452
|
-
|
|
609
|
+
#### `SafeImage.convert_favicon_to_png(from, to, optimize: true, max_pixels: nil)`
|
|
453
610
|
|
|
454
|
-
|
|
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
|
-
|
|
621
|
+
SafeImage.convert_favicon_to_png("favicon.ico", "favicon.png")
|
|
458
622
|
```
|
|
459
623
|
|
|
460
|
-
|
|
624
|
+
#### `SafeImage.letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")`
|
|
461
625
|
|
|
462
|
-
|
|
626
|
+
Generates a square letter avatar PNG: one grapheme blended in white at 80%
|
|
627
|
+
opacity over a solid background.
|
|
463
628
|
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
714
|
+
### SVG sanitising
|
|
508
715
|
|
|
509
|
-
|
|
716
|
+
#### `SafeImage.sanitize_svg!(path, id_namespace:)`
|
|
510
717
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
-
|
|
542
|
-
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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,
|
|
897
|
+
Without Landlock, everything above still applies; in particular the
|
|
898
|
+
ImageMagick path runs with:
|
|
568
899
|
|
|
569
|
-
- delegates
|
|
570
|
-
- filters
|
|
571
|
-
- `@file` indirection
|
|
572
|
-
- remote URL coders
|
|
573
|
-
- Ghostscript/document/vector formats
|
|
574
|
-
- coders
|
|
575
|
-
-
|
|
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
|
-
|
|
924
|
+
### Atomic Landlock sandboxing
|
|
596
925
|
|
|
597
|
-
Landlock support is optional, but atomic once
|
|
926
|
+
Landlock support is optional, but atomic once configured.
|
|
598
927
|
|
|
599
928
|
```ruby
|
|
600
|
-
SafeImage.sandbox_available?
|
|
601
|
-
SafeImage.
|
|
602
|
-
SafeImage.
|
|
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
|
-
|
|
606
|
-
|
|
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
|
|
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.
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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 #
|
|
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
|
|
672
|
-
|
|
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` |
|
|
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.
|