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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ce9a76b504fa826aef4164c5748c800a95828ef7fda6bda3a00e7e85f0134f38
|
|
4
|
+
data.tar.gz: 61c5ebac14806e8e3445c3e7aa73bb1ab5191cc414e1af311b752ad0be492bc7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5d15f34ca4de05f1ca4618695bc3993a000ce38a7de54afde86ac87497c0288ad0ed37493ca31431669341be10991bbcdb5192796ea6bdb3af016943adfac2bc
|
|
7
|
+
data.tar.gz: e71a59800452d4d396741e4e28134c47403490100233e803644a90a0ca967abd74fb5572a936a7af247411cb2055378982bccad89085085184aa92c66ca94d0d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.3.0 - 2026-06-12]
|
|
9
|
+
|
|
10
|
+
### Changed (breaking)
|
|
11
|
+
|
|
12
|
+
- **`sanitize_svg!` now requires `id_namespace:`.** The argument forces a
|
|
13
|
+
deliberate choice of where the output may be used, removing the footgun of a
|
|
14
|
+
silently-wrong default. Pass `:standalone` for output served only as an
|
|
15
|
+
external `<img>`/CSS-url/file, or a stable per-document String to make it safe
|
|
16
|
+
to inline (see below). Omitting it (or passing `nil`/`""`) raises
|
|
17
|
+
`ArgumentError`. Callers must update: `sanitize_svg!(path)` →
|
|
18
|
+
`sanitize_svg!(path, id_namespace: :standalone)`.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`sanitize_svg!` can produce output safe to inline into an HTML DOM.** Pass
|
|
23
|
+
`id_namespace:` a stable per-document value (e.g. the upload sha) and the
|
|
24
|
+
sanitizer prefixes every `id` and every reference to it (`href`/`xlink:href`
|
|
25
|
+
fragments, `url(#…)` in attributes and CSS, ARIA IDREF attributes like
|
|
26
|
+
`aria-labelledby`/`aria-controls`, and every `class` token plus the matching
|
|
27
|
+
`.class` selectors) with the namespace, and scopes every `<style>` selector
|
|
28
|
+
under a `<ns>-scope` class added to the root `<svg>`. Namespacing classes stops
|
|
29
|
+
an inlined SVG from invoking the host page's framework CSS (a bare
|
|
30
|
+
`class="modal fixed"` overlay vector). `var()`/`env()`/`attr()` in presentation
|
|
31
|
+
attributes are rejected outright — they resolve against the host page.
|
|
32
|
+
Inlined into a page, the preserved `<style>` can no longer reach the host
|
|
33
|
+
cascade (`*{visibility:hidden}`, `#header{display:none}`) and ids cannot
|
|
34
|
+
clobber host ids — including references written `URL(#x)`, `url('#x')`, or
|
|
35
|
+
`url("#x")`, which are namespaced like the unquoted form. In this mode the root
|
|
36
|
+
`<svg>`'s `overflow` is also dropped so it clips to its declared viewport (a
|
|
37
|
+
tiny viewport with `overflow:visible` and oversized content would otherwise
|
|
38
|
+
paint a full-page overlay). With `id_namespace: :standalone` the output is the
|
|
39
|
+
document-safe form (no namespacing). The namespace must be a valid ident (a
|
|
40
|
+
letter followed by letters/digits/`_`/`-`); malformed tokens are rejected
|
|
41
|
+
rather than coerced, so two distinct values can never collapse to one. The
|
|
42
|
+
transform is idempotent per namespace. `style=""` attributes are
|
|
43
|
+
element-scoped and inline-safe either way.
|
|
44
|
+
- **`<style>` elements now fail closed on any at-rule.** Previously an at-rule
|
|
45
|
+
block followed by a valid rule (`@font-face{…}.ok{…}`) could keep the trailing
|
|
46
|
+
rule; a stylesheet containing `@` anywhere is now rejected whole, matching the
|
|
47
|
+
documented guarantee.
|
|
48
|
+
- **The SVG sanitizer keeps a safe CSS subset instead of stripping all CSS.**
|
|
49
|
+
`style` attributes (as written by Inkscape) and `<style>` elements (as
|
|
50
|
+
written by Illustrator) now survive sanitisation when they parse against a
|
|
51
|
+
constructed allowlist grammar: properties mirroring the allowed
|
|
52
|
+
presentation attributes, type/class/id selectors, numeric/keyword/color
|
|
53
|
+
values, and `url(#fragment)` references only. Output is reassembled from
|
|
54
|
+
validated tokens — CSS escapes, quotes/strings, at-rules, comments, and
|
|
55
|
+
unknown properties, functions, or selectors drop the declaration, rule, or
|
|
56
|
+
whole stylesheet rather than being interpreted. A single at-rule or nested
|
|
57
|
+
block fails the whole `<style>` element closed. `!important` and modern
|
|
58
|
+
`rgb()/hsl()` slash-alpha (`rgb(R G B / A)`) are preserved; both are parsed
|
|
59
|
+
structurally and re-emitted, and admitting `/` for the alpha keeps CSS
|
|
60
|
+
comments impossible because `*` remains excluded from the value charset.
|
|
61
|
+
- **The presentation-attribute allowlist covers common editor output.** Added
|
|
62
|
+
the safe, widely-emitted SVG presentation properties (and their CSS twins):
|
|
63
|
+
`stroke-dasharray`/`stroke-dashoffset`, `vector-effect`, `marker`/`marker-*`
|
|
64
|
+
(with the `<marker>` element and its geometry attributes), `color`,
|
|
65
|
+
`display`/`visibility`/`overflow`, `paint-order`/`mix-blend-mode`/`isolation`,
|
|
66
|
+
the `*-rendering` hints, and the longhand text properties (`font-style`,
|
|
67
|
+
`font-variant`, `font-stretch`, `text-decoration`, `letter-spacing`,
|
|
68
|
+
`word-spacing`, `dominant-baseline`, `baseline-shift`, `writing-mode`,
|
|
69
|
+
`direction`). The only additions carrying a URL — `marker*` — are constrained
|
|
70
|
+
to `url(#fragment)` like the existing paint and clip/mask references. Filters
|
|
71
|
+
remain out of scope.
|
|
72
|
+
|
|
73
|
+
### Changed
|
|
74
|
+
|
|
75
|
+
- **Sandboxed operations are served by a pool of resident zygote workers —
|
|
76
|
+
warm Landlock overhead drops from ~100ms to ~3–8ms per operation, with
|
|
77
|
+
near-linear concurrency.** Previously every sandboxed call exec'd a fresh
|
|
78
|
+
Ruby that re-paid interpreter boot, rubygems, the gem's requires, and
|
|
79
|
+
libvips init. A zygote boots that once, then forks a child per operation;
|
|
80
|
+
the child applies rlimits, its per-operation Landlock policy (filesystem
|
|
81
|
+
allowlist, all TCP denied on ABI ≥ 4, abstract-unix-socket/signal scopes on
|
|
82
|
+
ABI ≥ 6), and — when the installed `landlock` gem exposes
|
|
83
|
+
`seccomp_deny_network!` — the helper's deny-all-network seccomp filter
|
|
84
|
+
(blocking sockets of every family, closing the UDP gap the in-process
|
|
85
|
+
Landlock policy alone leaves open), *before* touching untrusted input, and
|
|
86
|
+
exits after the operation. Workers are pooled so N threads run N sandboxed
|
|
87
|
+
operations at once (the single zygote would serialise them): the pool grows
|
|
88
|
+
on demand to `SAFE_IMAGE_ZYGOTE_WORKERS` (default 8) and offered concurrency
|
|
89
|
+
past the cap blocks until a worker frees, bounding concurrent libvips
|
|
90
|
+
memory. An idle worker exits after `Zygote::IDLE_SECONDS` (300, overridable
|
|
91
|
+
via `SAFE_IMAGE_ZYGOTE_IDLE_SECONDS`; idling holds ~16MB private memory and
|
|
92
|
+
no CPU), exits immediately when its parent process does, and `configure!`
|
|
93
|
+
always retires the pool, so no stale process outlives its parent or a
|
|
94
|
+
reconfigure. Forking is sound because the zygote never runs operations
|
|
95
|
+
itself: libvips is initialised but quiescent (zero native threads) at every
|
|
96
|
+
fork — verified empirically. Outputs are byte-identical to both the exec
|
|
97
|
+
worker and unsandboxed operation. `SAFE_IMAGE_ZYGOTE=0` falls back to the
|
|
98
|
+
exec-per-operation worker. Pool correctness under concurrent reconfigure,
|
|
99
|
+
worker death, and fork is enforced by a per-worker generation token (a
|
|
100
|
+
worker checked out when `configure!` lands is retired on return, never
|
|
101
|
+
reused under stale config), channel-health (not process-liveness) reuse
|
|
102
|
+
decisions (a worker whose pipe broke is discarded, never re-pooled), a
|
|
103
|
+
`PR_SET_PDEATHSIG` so an operation child dies with its zygote, and
|
|
104
|
+
parent-side tmp-root cleanup that survives a SIGKILLed worker — all
|
|
105
|
+
exercised by a multithreaded chaos stress test (reconfigure + worker kills)
|
|
106
|
+
asserting no wrong output, no process/fd/tmpdir leak, and no deadlock.
|
|
107
|
+
|
|
108
|
+
- **External tools run with `MALLOC_ARENA_MAX=2`.** Multithreaded optimizer
|
|
109
|
+
tools (oxipng's rayon pool, ImageMagick's OpenMP) otherwise have glibc
|
|
110
|
+
reserve a 64MB malloc arena per thread; combined with the sandbox's
|
|
111
|
+
`RLIMIT_AS` memory cap, that *address-space* reservation spuriously fails
|
|
112
|
+
the tool under concurrency even though real memory use is tiny. Bounding the
|
|
113
|
+
arena count is the standard mitigation and is free for these compute-bound
|
|
114
|
+
tools. Found by stress-testing concurrent sandboxed `convert`.
|
|
115
|
+
|
|
116
|
+
- **rexml loads on first SVG use instead of at `require "safe_image"`.**
|
|
117
|
+
rexml costs ~27ms to parse and only the SVG paths need it, so every boot of
|
|
118
|
+
the gem — and in particular every Landlock sandbox worker, which is a fresh
|
|
119
|
+
Ruby process per operation — was paying it on operations that never touch
|
|
120
|
+
SVG. Measured per-op sandbox cost drops from ~102ms to ~85ms on the vips
|
|
121
|
+
backend and ~75ms to ~58ms on the imagemagick backend.
|
|
122
|
+
|
|
123
|
+
- **Remote fetches reject bad responses from the headers alone.** The
|
|
124
|
+
`Content-Type` allowlist and content-type/extension agreement checks now run
|
|
125
|
+
before any body bytes are read (previously the body was downloaded first and
|
|
126
|
+
rejected afterwards), and the first bytes of the body must be compatible
|
|
127
|
+
with the claimed format's magic bytes — an obviously mislabeled body is
|
|
128
|
+
dropped after the first chunk instead of being downloaded to `max_bytes`.
|
|
129
|
+
- **Remote metadata helpers download only what the answer needs.**
|
|
130
|
+
`remote_size`, `remote_type`, `remote_info` and `remote_animated?` now probe
|
|
131
|
+
the partially-downloaded file at growing thresholds (64KB, 256KB, ...) and
|
|
132
|
+
abort the transfer once the answer is final, instead of always downloading
|
|
133
|
+
up to `max_bytes`. Early answers are only trusted when more data cannot
|
|
134
|
+
change them: "not animated" still requires the complete file (truncated
|
|
135
|
+
animations undercount frames), SVG metadata still downloads the whole
|
|
136
|
+
document so the SVG size cap keeps its meaning, and any prefix probe
|
|
137
|
+
failure falls back to the full download with unchanged validation and
|
|
138
|
+
error behaviour. `fetch_remote` and `remote_dominant_color` still download
|
|
139
|
+
the complete body.
|
|
140
|
+
|
|
141
|
+
### Fixed
|
|
142
|
+
|
|
143
|
+
- **Lossy PNG optimisation no longer fails when pngquant declines to
|
|
144
|
+
quantise.** pngquant signals "quantised result not used" through exit
|
|
145
|
+
status 98 (`--skip-if-larger`) and 99 (`--quality` not met) — for example
|
|
146
|
+
on low-bit-depth grayscale PNGs its RGBA-palette output cannot beat —
|
|
147
|
+
and `optimize(mode: :lossy)` raised `CommandError` instead of keeping the
|
|
148
|
+
original and continuing to oxipng. Found by running 10 random Wikimedia
|
|
149
|
+
Commons images through the optimizer.
|
|
150
|
+
|
|
151
|
+
- **`optimize` no longer ships sideways JPEGs.** With `strip_metadata: true`
|
|
152
|
+
(the default), stripping deleted the EXIF orientation tag without applying
|
|
153
|
+
the rotation, so an oriented camera photo came out rendered 90/180° wrong.
|
|
154
|
+
`optimize` now bakes the rotation into the pixels first via jpegtran's
|
|
155
|
+
lossless transforms: `-perfect` when the dimensions are MCU-aligned, else
|
|
156
|
+
`-trim`, which drops the partial edge blocks (under one MCU, at most 15px)
|
|
157
|
+
instead of hiding a lossy re-encode. The result hash gains `rotated_from:`
|
|
158
|
+
and `trimmed:` so the trim is reported, never silent — image_optim's jhead
|
|
159
|
+
worker (Discourse's `FileHelper.optimize_image!`) does the same transform
|
|
160
|
+
but trims silently. Without jpegtran an oriented JPEG raises in strict mode
|
|
161
|
+
and is left untouched otherwise; reading the tag goes through the configured
|
|
162
|
+
backend, so `optimize` now also enforces the pixel cap before touching an
|
|
163
|
+
oriented JPEG. Internal callers optimising output the gem just encoded skip
|
|
164
|
+
the check (`assume_upright:`).
|
|
165
|
+
|
|
166
|
+
- **JPEGs with an EXIF orientation no longer fail on the libvips path once
|
|
167
|
+
they outgrow the sequential readahead window (~512px — every real camera
|
|
168
|
+
photo).** `resize`, `crop`, `convert`/`convert_to_jpeg` and the
|
|
169
|
+
`fix_orientation` re-encode tier loaded input with `access: sequential` and
|
|
170
|
+
then autorotated, and the rotation's out-of-order row reads raised
|
|
171
|
+
`VipsJpeg: out of order read`. Oriented images are now reloaded with random
|
|
172
|
+
access before autorotation; upright images keep the streaming sequential
|
|
173
|
+
load, and the pixel cap still runs before any decode.
|
|
174
|
+
|
|
175
|
+
### Security
|
|
176
|
+
|
|
177
|
+
- **Presentation-attribute `url()` references fail closed unless they are a
|
|
178
|
+
canonical same-document fragment.** A single validation/rewrite grammar now
|
|
179
|
+
governs both the keep decision and the namespace rewrite, so external URLs,
|
|
180
|
+
mismatched quotes, and unterminated forms (`url(#id`, `url(http://evil`) are
|
|
181
|
+
dropped rather than kept on browser parse-error leniency — and no bare,
|
|
182
|
+
un-namespaced reference can survive in inline (`id_namespace:` String) output.
|
|
183
|
+
- **Attribute values containing CSS escapes are rejected outright.**
|
|
184
|
+
Browsers feed SVG presentation attributes through their CSS value parsers,
|
|
185
|
+
where an escape can re-form a token after the sanitizer's pattern checks
|
|
186
|
+
(`ur\6c(...)` is `url(...)`). No allowlisted attribute legitimately
|
|
187
|
+
contains a backslash, so any attribute value with one is now dropped.
|
|
188
|
+
- **SVG parsing rejects encodings the byte-level guards cannot see through.**
|
|
189
|
+
The DOCTYPE/processing-instruction guards are ASCII byte scans; a UTF-16
|
|
190
|
+
document interleaves NUL bytes between the ASCII characters, so a
|
|
191
|
+
`<!DOCTYPE` (and an entity payload behind it) could slip past them while
|
|
192
|
+
REXML still decoded and honoured it. SVG documents must now be UTF-8 (BOM
|
|
193
|
+
allowed) or declare a single-byte ASCII-transparent charset (US-ASCII,
|
|
194
|
+
ISO-8859-*, Windows-125x): UTF-16/32 BOMs, embedded NUL bytes, and declared
|
|
195
|
+
multi-byte or transforming encodings (Shift_JIS, GBK, EUC-*, ISO-2022-*,
|
|
196
|
+
UTF-7) raise `InvalidImageError`. Declared names that fit the allowed shape
|
|
197
|
+
but resolve to no real encoding (e.g. `utf8`, `windows-1259`) also fail
|
|
198
|
+
closed as `InvalidImageError` instead of surfacing REXML's bare
|
|
199
|
+
`ArgumentError`.
|
|
200
|
+
|
|
201
|
+
## [0.2.0] - 2026-06-10
|
|
202
|
+
|
|
203
|
+
The host's whole image-processing posture is now decided in one place, once,
|
|
204
|
+
at boot. There is no per-call backend fidelity and no automatic routing
|
|
205
|
+
between backends.
|
|
206
|
+
|
|
207
|
+
### Added
|
|
208
|
+
|
|
209
|
+
- **`SafeImage.configure!(backend:, landlock:, max_pixels:)` is mandatory**:
|
|
210
|
+
every operation before it raises the new `SafeImage::NotConfiguredError`.
|
|
211
|
+
`backend:` (`:vips` or `:imagemagick`) picks the decoder for all untrusted
|
|
212
|
+
bytes; `landlock:` decides sandboxing; `max_pixels:` sets the default
|
|
213
|
+
decompression-bomb ceiling (128MP, still overridable per call). Validation
|
|
214
|
+
is eager — a missing libvips, ImageMagick binary, or Landlock support fails
|
|
215
|
+
at boot, not on the first request. Calling `configure!` again replaces the
|
|
216
|
+
configuration atomically, so initializer reloads are safe.
|
|
217
|
+
- `SafeImage.config` (frozen current configuration) and
|
|
218
|
+
`SafeImage.configured?`.
|
|
219
|
+
|
|
220
|
+
### Changed
|
|
221
|
+
|
|
222
|
+
- **Backend selection is strict.** `:auto` is gone; nothing ever falls back
|
|
223
|
+
from libvips to ImageMagick. A format the configured backend cannot decode
|
|
224
|
+
(e.g. ICO transform input on `:vips`, HEIC on a libvips build without
|
|
225
|
+
libheif) raises `UnsupportedFormatError`. Pure-Ruby format handling (SVG
|
|
226
|
+
metadata/sanitising, ICO metadata) still works on either backend.
|
|
227
|
+
- **cjpegli is availability-driven**, like the optimizer tools: installed
|
|
228
|
+
means used for JPEG output on the `:vips` backend, absent means libvips
|
|
229
|
+
encodes. It is no longer a per-call or configuration choice.
|
|
230
|
+
- Sandbox worker processes inherit the parent's backend and pixel-ceiling
|
|
231
|
+
configuration through the request payload (landlock is forced off inside
|
|
232
|
+
the worker, so sandboxed operations never nest).
|
|
233
|
+
- `dominant_color` on the `:imagemagick` backend now decodes ICO through
|
|
234
|
+
ImageMagick; the pure-Ruby ICO decoder serves the `:vips` backend.
|
|
235
|
+
|
|
236
|
+
### Removed
|
|
237
|
+
|
|
238
|
+
- Per-call `backend:` keyword on `resize`, `crop`, `downsize`, `convert`,
|
|
239
|
+
`thumbnail`, `letter_avatar`, `fix_orientation` and `dominant_color`.
|
|
240
|
+
- Per-call `encoder:` keyword on `convert`, `convert_to_jpeg`, `thumbnail`,
|
|
241
|
+
`resize`, `crop` and `downsize`.
|
|
242
|
+
- `execution:` keyword on `thumbnail` (`:sandbox` / `:sandbox_if_available`)
|
|
243
|
+
— sandboxing is decided by `configure!(landlock:)`.
|
|
244
|
+
- `SafeImage.enable_sandbox!`, `SafeImage.disable_sandbox!`,
|
|
245
|
+
`SafeImage.sandbox_enabled?` and `SafeImage.sandbox_call` —
|
|
246
|
+
`SafeImage.sandbox_available?` remains as the pre-configuration probe.
|
|
247
|
+
|
|
248
|
+
## [0.1.1] - 2026-06-10
|
|
249
|
+
|
|
250
|
+
### Added
|
|
251
|
+
|
|
252
|
+
- `SafeImage.dominant_color` and `SafeImage.remote_dominant_color`: alpha-weighted
|
|
253
|
+
average colour as an `RRGGBB` hex string, computed natively through libvips,
|
|
254
|
+
with an ImageMagick histogram backend (`backend: :imagemagick`) matching the
|
|
255
|
+
command Discourse runs.
|
|
256
|
+
- Pure-Ruby ICO support: directory parsing for `probe`/`frame_count`,
|
|
257
|
+
largest-entry favicon extraction for `convert_favicon_to_png` (embedded PNG
|
|
258
|
+
payloads are sanitised by re-encoding; legacy DIB payloads decode 1/4/8/24/32bpp
|
|
259
|
+
with the AND mask), and decompression-bomb caps enforced from the container
|
|
260
|
+
and IHDR headers before any decode.
|
|
261
|
+
- Native GIF decode through libvips' bundled libnsgif loader (first frame,
|
|
262
|
+
matching the ImageMagick `[0]` semantics) and GIF output through cgif.
|
|
263
|
+
- JPEG XL support end to end: native libvips loader/saver, ImageMagick coder
|
|
264
|
+
and policy allowlisting, remote content-type/extension handling, and a
|
|
265
|
+
committed test fixture.
|
|
266
|
+
- Native letter avatars rendered through libvips' Pango text support with the
|
|
267
|
+
glyph blended in one linear transform; the gem now bundles DejaVu Sans (see
|
|
268
|
+
`lib/safe_image/fonts/DEJAVU-LICENSE`) so the default font renders
|
|
269
|
+
identically on every host with no font packages installed.
|
|
270
|
+
- Header-only native metadata reads: `frame_count`/`animated?` from the
|
|
271
|
+
n-pages field and `orientation` from the orientation field — no pixel decode
|
|
272
|
+
and no `identify` subprocess for natively supported formats.
|
|
273
|
+
- `fix_orientation` lossless tier: MCU-aligned JPEGs are transformed with
|
|
274
|
+
`jpegtran` (zero generation loss) when installed, falling back to a libvips
|
|
275
|
+
re-encode with a new `quality:` keyword (default 95).
|
|
276
|
+
- Native `convert` tier: decode through the allowlisted loaders,
|
|
277
|
+
auto-orient, flatten transparency onto white for JPEG targets (matching the
|
|
278
|
+
ImageMagick path), re-encode; default JPEG quality 92 when unspecified.
|
|
279
|
+
- `backend:` keyword (`:auto` / `:vips` / `:imagemagick`) across
|
|
280
|
+
`resize`, `crop`, `downsize`, `convert`, `thumbnail`, `letter_avatar`,
|
|
281
|
+
`fix_orientation` and `dominant_color`. `:auto` prefers the native path and
|
|
282
|
+
uses ImageMagick only for capabilities libvips cannot serve; `:vips` fails
|
|
283
|
+
closed; `:imagemagick` pins the compatibility pipeline.
|
|
284
|
+
- Graceful operation without libvips: the new `SafeImage::VipsUnavailableError`
|
|
285
|
+
(a subclass of `UnsupportedFormatError`) makes `backend: :auto` route through
|
|
286
|
+
ImageMagick on hosts without the library, while explicit `backend: :vips`
|
|
287
|
+
calls fail closed. `SafeImage::VipsGlue.available?` reports the state.
|
|
288
|
+
- `docker/run.sh`: containerised validation against Debian bookworm's packaged
|
|
289
|
+
libvips 8.14 with no toolchain installed.
|
|
290
|
+
|
|
291
|
+
### Changed
|
|
292
|
+
|
|
293
|
+
- **The compiled C extension is gone.** libvips is now bound at runtime
|
|
294
|
+
through a minimal Fiddle binding (`SafeImage::VipsGlue`) that exposes only
|
|
295
|
+
the operations the gem invokes. Nothing compiles at gem install time; the
|
|
296
|
+
only gem dependencies are `fiddle` and `rexml`. Minimum libvips is 8.13
|
|
297
|
+
(Debian bookworm's 8.14 package is tested); `SAFE_IMAGE_LIBVIPS` overrides
|
|
298
|
+
the library name.
|
|
299
|
+
- All transform defaults are now native-first: `resize`, `crop`, `downsize`,
|
|
300
|
+
`convert` and `thumbnail` default to `backend: :auto` (previously
|
|
301
|
+
ImageMagick for the first three and `:vips` fail-closed for `thumbnail`).
|
|
302
|
+
Pin `backend: :imagemagick` where byte-similar output with previously
|
|
303
|
+
generated thumbnails matters.
|
|
304
|
+
- The default letter avatar font is now `DejaVu-Sans` (bundled), and the
|
|
305
|
+
native renderer centres the glyph's ink box optically; the ImageMagick
|
|
306
|
+
path's baseline placement (which could clip descenders) remains available
|
|
307
|
+
via `backend: :imagemagick`. Regenerate cached avatars when switching.
|
|
308
|
+
- `convert_favicon_to_png` extracts the largest ICO entry rather than the
|
|
309
|
+
last one, and `probe` on ICO reports the largest entry's dimensions from a
|
|
310
|
+
pure-Ruby directory parse.
|
|
311
|
+
- libvips' GLib warnings about rejected input (e.g. "Not a PNG file") are
|
|
312
|
+
suppressed by default — failures already surface as exceptions; set
|
|
313
|
+
`SAFE_IMAGE_VIPS_WARNINGS=1` to restore them for debugging.
|
|
314
|
+
|
|
315
|
+
### Fixed
|
|
316
|
+
|
|
317
|
+
- `resize`/`crop`/`downsize`/`convert` with `optimize: true` no longer raise
|
|
318
|
+
for output formats the optimizer tools cannot handle (GIF, JPEG XL); the
|
|
319
|
+
optimize pass is skipped instead.
|
|
320
|
+
- In-place `fix_orientation` writes through a sibling tempfile and renames,
|
|
321
|
+
so libvips never streams its input into itself.
|
|
322
|
+
- Garbage EXIF orientation tags clamp to the valid 1–8 range instead of
|
|
323
|
+
leaking raw values.
|
|
324
|
+
- The Landlock sandbox grants read access to Ruby's `libdir`, so workers no
|
|
325
|
+
longer fail to start under `--enable-shared` Rubies installed outside the
|
|
326
|
+
default read roots (e.g. GitHub Actions' hostedtoolcache builds). Sandbox
|
|
327
|
+
failures now include the child's stderr in the error message.
|
|
328
|
+
|
|
329
|
+
### Security
|
|
330
|
+
|
|
331
|
+
- The bundled ImageMagick `policy.xml` gained write-only `HISTOGRAM`/`INFO`
|
|
332
|
+
coders (for the dominant-colour backend) and the `JXL` coder, plus a
|
|
333
|
+
regression test asserting the policy parses completely — ImageMagick's
|
|
334
|
+
hand-rolled tokenizer silently truncates the file on a stray backtick or
|
|
335
|
+
apostrophe in a comment.
|
|
336
|
+
- The libjxl loader/saver are deliberately re-enabled from libvips'
|
|
337
|
+
untrusted-operation block: JPEG XL is part of the supported input surface,
|
|
338
|
+
and inputs still pass extension routing, pixel caps and (optionally) the
|
|
339
|
+
Landlock sandbox.
|
|
340
|
+
- The Fiddle binding doubles as an operation allowlist, and a leak-loop test
|
|
341
|
+
guards GObject reference handling.
|
|
342
|
+
|
|
343
|
+
## [0.1.0] - 2026-06-09
|
|
344
|
+
|
|
345
|
+
Initial release: hardened image-processing boundary for untrusted uploads.
|
|
346
|
+
Probing and metadata helpers, thumbnails/resize/crop/downsize/convert through
|
|
347
|
+
a native libvips fast path with an ImageMagick compatibility backend under a
|
|
348
|
+
restrictive bundled policy, JPEG/PNG optimisation (jpegoptim/oxipng/pngquant),
|
|
349
|
+
optional Jpegli encoding, allowlist-based SVG sanitising, SSRF-hardened remote
|
|
350
|
+
fetching with DNS pinning, symlink-safe path handling, 128MP default pixel
|
|
351
|
+
caps, and optional atomic Landlock sandboxing.
|