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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb95e7819270c76a02c8ca67321d234f945cb572098e80055e43b44fac0c84ce
4
- data.tar.gz: 3886ea56e99336f15cc1afd124ac57e200052120b2f61dd98bb17240fe1d3d04
3
+ metadata.gz: ce9a76b504fa826aef4164c5748c800a95828ef7fda6bda3a00e7e85f0134f38
4
+ data.tar.gz: 61c5ebac14806e8e3445c3e7aa73bb1ab5191cc414e1af311b752ad0be492bc7
5
5
  SHA512:
6
- metadata.gz: e5da964df60b257eb3d72df410379c9ae52b66375d8b75f165a0ad49953099e7f41c894f896d3d8c2371fff4337d53d096cb78657dfba2c7f455f14c1d9bb873
7
- data.tar.gz: 201b2f6499be3cc1bb10141c55688564022dca6f133760ecaccf6bb0502b536eb6e1ba2d1bc3824c9909d61474ac9ba5db35f812a85326b7c8cf387d4d947719
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.