safe_image 0.2.0 → 0.4.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -10
  3. data/README.md +176 -168
  4. data/SECURITY.md +22 -11
  5. data/docs/architecture.md +63 -0
  6. data/ext/safe_image_vips_helper/extconf.rb +43 -0
  7. data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
  8. data/lib/safe_image/api/metadata.rb +85 -0
  9. data/lib/safe_image/api/transform.rb +152 -0
  10. data/lib/safe_image/backend_label.rb +24 -0
  11. data/lib/safe_image/formats.rb +96 -0
  12. data/lib/safe_image/ico.rb +43 -41
  13. data/lib/safe_image/image_magick_backend.rb +219 -162
  14. data/lib/safe_image/jpegli_backend.rb +64 -44
  15. data/lib/safe_image/metadata_operations.rb +155 -0
  16. data/lib/safe_image/native.rb +96 -281
  17. data/lib/safe_image/native_helper.rb +281 -0
  18. data/lib/safe_image/operation_backends/base.rb +83 -0
  19. data/lib/safe_image/operation_backends/image_magick.rb +123 -0
  20. data/lib/safe_image/operation_backends/vips.rb +251 -0
  21. data/lib/safe_image/operation_backends.rb +27 -0
  22. data/lib/safe_image/operation_set.rb +22 -0
  23. data/lib/safe_image/optimizer.rb +250 -48
  24. data/lib/safe_image/path_safety.rb +11 -0
  25. data/lib/safe_image/processor.rb +122 -85
  26. data/lib/safe_image/quality_defaults.rb +23 -0
  27. data/lib/safe_image/remote.rb +367 -97
  28. data/lib/safe_image/result.rb +71 -23
  29. data/lib/safe_image/runner.rb +69 -24
  30. data/lib/safe_image/sandbox.rb +44 -191
  31. data/lib/safe_image/staged_output.rb +44 -0
  32. data/lib/safe_image/svg_metadata.rb +186 -55
  33. data/lib/safe_image/transform_operations.rb +139 -0
  34. data/lib/safe_image/version.rb +1 -1
  35. data/lib/safe_image/vips_backend.rb +13 -12
  36. data/lib/safe_image.rb +62 -294
  37. metadata +48 -26
  38. data/lib/safe_image/discourse_compat.rb +0 -452
  39. data/lib/safe_image/svg_sanitizer.rb +0 -102
  40. data/lib/safe_image/vips_glue.rb +0 -361
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e078245fa2e4fb0707b0d477ad0c4b41f31423905ed0dc84f964cd648a5a032f
4
- data.tar.gz: 9155876c2761ac9c6afb2b4da4a97af8b6b7e035ffee553c72dacda6166c1048
3
+ metadata.gz: 1f73bfd1e95c6fbb93ad43254cbff0b2d14cf1d667b5af58daf3e7f2311e3240
4
+ data.tar.gz: 598bc5111672e82b922bfc134c168488ad9348b2b85cf5d6f3ac6d12c959ddfc
5
5
  SHA512:
6
- metadata.gz: 2f98d64d3b9665a156c27cbcef898cd148e6b559c73c03ba757b1147d2e47f1733d5a6e0ebc7d5c038cb989f874d67747175cd1d094cdf9677f98550cfe635be
7
- data.tar.gz: ae1008885bf83da844145369cd929edd12c0af238fd0d975e2c078ef95b4d7cc301f1fed2da5cdd815d520591938acc144a2bd79d4bd2573336a2c9eb1078e5b
6
+ metadata.gz: d3dd059f65b1f5d5ad43290aaf3e654817eca8d5127f90717cbf91739c31b92c9674c5fcc73c5b61a5b2be724c5b8053e298a35f6063cd880959a3ccfb9916c8
7
+ data.tar.gz: 5eeb7da08494d4e76f6ba5e9aa6479b083c8e1cd8f40fa7779537c00884a0e5cb068714b6fbf6ece00fc82fcdc39919c458c2fadcc4740b1348786a88871fb7a
data/CHANGELOG.md CHANGED
@@ -5,6 +5,211 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0 - 2026-06-18]
9
+
10
+ ### Removed (breaking)
11
+
12
+ - Removed `SafeImage.sanitize_svg!` and the internal SVG/CSS sanitizer
13
+ implementation. Safe Image still supports bounded SVG metadata probing for
14
+ local and remote `.svg` files, but applications that need to clean or inline
15
+ user-supplied SVG content must use a dedicated sanitizer outside this gem.
16
+ - Removed the compatibility/in-place file-writing APIs. `resize`, `crop`,
17
+ `downsize`, `convert`, `fix_orientation`, `convert_favicon_to_png`, and
18
+ `optimize` now require explicit, distinct `input:` and `output:` paths;
19
+ `optimize_image!` and `convert_to_jpeg` are gone.
20
+
21
+ ## [0.3.0 - 2026-06-12]
22
+
23
+ ### Changed (breaking)
24
+
25
+ - **`sanitize_svg!` now requires `id_namespace:`.** The argument forces a
26
+ deliberate choice of where the output may be used, removing the footgun of a
27
+ silently-wrong default. Pass `:standalone` for output served only as an
28
+ external `<img>`/CSS-url/file, or a stable per-document String to make it safe
29
+ to inline (see below). Omitting it (or passing `nil`/`""`) raises
30
+ `ArgumentError`. Callers must update: `sanitize_svg!(path)` →
31
+ `sanitize_svg!(path, id_namespace: :standalone)`.
32
+
33
+ ### Added
34
+
35
+ - **`sanitize_svg!` can produce output safe to inline into an HTML DOM.** Pass
36
+ `id_namespace:` a stable per-document value (e.g. the upload sha) and the
37
+ sanitizer prefixes every `id` and every reference to it (`href`/`xlink:href`
38
+ fragments, `url(#…)` in attributes and CSS, ARIA IDREF attributes like
39
+ `aria-labelledby`/`aria-controls`, and every `class` token plus the matching
40
+ `.class` selectors) with the namespace, and scopes every `<style>` selector
41
+ under a `<ns>-scope` class added to the root `<svg>`. Namespacing classes stops
42
+ an inlined SVG from invoking the host page's framework CSS (a bare
43
+ `class="modal fixed"` overlay vector). `var()`/`env()`/`attr()` in presentation
44
+ attributes are rejected outright — they resolve against the host page.
45
+ Inlined into a page, the preserved `<style>` can no longer reach the host
46
+ cascade (`*{visibility:hidden}`, `#header{display:none}`) and ids cannot
47
+ clobber host ids — including references written `URL(#x)`, `url('#x')`, or
48
+ `url("#x")`, which are namespaced like the unquoted form. In this mode the root
49
+ `<svg>`'s `overflow` is also dropped so it clips to its declared viewport (a
50
+ tiny viewport with `overflow:visible` and oversized content would otherwise
51
+ paint a full-page overlay). With `id_namespace: :standalone` the output is the
52
+ document-safe form (no namespacing). The namespace must be a valid ident (a
53
+ letter followed by letters/digits/`_`/`-`); malformed tokens are rejected
54
+ rather than coerced, so two distinct values can never collapse to one. The
55
+ transform is idempotent per namespace. `style=""` attributes are
56
+ element-scoped and inline-safe either way.
57
+ - **`<style>` elements now fail closed on any at-rule.** Previously an at-rule
58
+ block followed by a valid rule (`@font-face{…}.ok{…}`) could keep the trailing
59
+ rule; a stylesheet containing `@` anywhere is now rejected whole, matching the
60
+ documented guarantee.
61
+ - **The SVG sanitizer keeps a safe CSS subset instead of stripping all CSS.**
62
+ `style` attributes (as written by Inkscape) and `<style>` elements (as
63
+ written by Illustrator) now survive sanitisation when they parse against a
64
+ constructed allowlist grammar: properties mirroring the allowed
65
+ presentation attributes, type/class/id selectors, numeric/keyword/color
66
+ values, and `url(#fragment)` references only. Output is reassembled from
67
+ validated tokens — CSS escapes, quotes/strings, at-rules, comments, and
68
+ unknown properties, functions, or selectors drop the declaration, rule, or
69
+ whole stylesheet rather than being interpreted. A single at-rule or nested
70
+ block fails the whole `<style>` element closed. `!important` and modern
71
+ `rgb()/hsl()` slash-alpha (`rgb(R G B / A)`) are preserved; both are parsed
72
+ structurally and re-emitted, and admitting `/` for the alpha keeps CSS
73
+ comments impossible because `*` remains excluded from the value charset.
74
+ - **The presentation-attribute allowlist covers common editor output.** Added
75
+ the safe, widely-emitted SVG presentation properties (and their CSS twins):
76
+ `stroke-dasharray`/`stroke-dashoffset`, `vector-effect`, `marker`/`marker-*`
77
+ (with the `<marker>` element and its geometry attributes), `color`,
78
+ `display`/`visibility`/`overflow`, `paint-order`/`mix-blend-mode`/`isolation`,
79
+ the `*-rendering` hints, and the longhand text properties (`font-style`,
80
+ `font-variant`, `font-stretch`, `text-decoration`, `letter-spacing`,
81
+ `word-spacing`, `dominant-baseline`, `baseline-shift`, `writing-mode`,
82
+ `direction`). The only additions carrying a URL — `marker*` — are constrained
83
+ to `url(#fragment)` like the existing paint and clip/mask references. Filters
84
+ remain out of scope.
85
+
86
+ ### Changed
87
+
88
+ - **Sandboxed operations are served by a pool of resident zygote workers —
89
+ warm Landlock overhead drops from ~100ms to ~3–8ms per operation, with
90
+ near-linear concurrency.** Previously every sandboxed call exec'd a fresh
91
+ Ruby that re-paid interpreter boot, rubygems, the gem's requires, and
92
+ libvips init. A zygote boots that once, then forks a child per operation;
93
+ the child applies rlimits, its per-operation Landlock policy (filesystem
94
+ allowlist, all TCP denied on ABI ≥ 4, abstract-unix-socket/signal scopes on
95
+ ABI ≥ 6), and — when the installed `landlock` gem exposes
96
+ `seccomp_deny_network!` — the helper's deny-all-network seccomp filter
97
+ (blocking sockets of every family), *before* touching untrusted input, and
98
+ exits after the operation. Workers are pooled so N threads run N sandboxed
99
+ operations at once (the single zygote would serialise them): the pool grows
100
+ on demand to `SAFE_IMAGE_ZYGOTE_WORKERS` (default 8) and offered concurrency
101
+ past the cap blocks until a worker frees, bounding concurrent libvips
102
+ memory. An idle worker exits after `Zygote::IDLE_SECONDS` (300, overridable
103
+ via `SAFE_IMAGE_ZYGOTE_IDLE_SECONDS`; idling holds ~16MB private memory and
104
+ no CPU), exits immediately when its parent process does, and `configure!`
105
+ always retires the pool, so no stale process outlives its parent or a
106
+ reconfigure. Forking is sound because the zygote never runs operations
107
+ itself: libvips is initialised but quiescent (zero native threads) at every
108
+ fork — verified empirically. Outputs are byte-identical to both the exec
109
+ worker and unsandboxed operation. `SAFE_IMAGE_ZYGOTE=0` falls back to the
110
+ exec-per-operation worker. Pool correctness under concurrent reconfigure,
111
+ worker death, and fork is enforced by a per-worker generation token (a
112
+ worker checked out when `configure!` lands is retired on return, never
113
+ reused under stale config), channel-health (not process-liveness) reuse
114
+ decisions (a worker whose pipe broke is discarded, never re-pooled), a
115
+ `PR_SET_PDEATHSIG` so an operation child dies with its zygote, and
116
+ parent-side tmp-root cleanup that survives a SIGKILLed worker — all
117
+ exercised by a multithreaded chaos stress test (reconfigure + worker kills)
118
+ asserting no wrong output, no process/fd/tmpdir leak, and no deadlock.
119
+
120
+ - **External tools run with `MALLOC_ARENA_MAX=2`.** Multithreaded optimizer
121
+ tools (oxipng's rayon pool, ImageMagick's OpenMP) otherwise have glibc
122
+ reserve a 64MB malloc arena per thread; combined with the sandbox's
123
+ `RLIMIT_AS` memory cap, that *address-space* reservation spuriously fails
124
+ the tool under concurrency even though real memory use is tiny. Bounding the
125
+ arena count is the standard mitigation and is free for these compute-bound
126
+ tools. Found by stress-testing concurrent sandboxed `convert`.
127
+
128
+ - **rexml loads on first SVG use instead of at `require "safe_image"`.**
129
+ rexml costs ~27ms to parse and only the SVG paths need it, so every boot of
130
+ the gem — and in particular every Landlock sandbox worker, which is a fresh
131
+ Ruby process per operation — was paying it on operations that never touch
132
+ SVG. Measured per-op sandbox cost drops from ~102ms to ~85ms on the vips
133
+ backend and ~75ms to ~58ms on the imagemagick backend.
134
+
135
+ - **Remote fetches reject bad responses from the headers alone.** The
136
+ `Content-Type` allowlist and content-type/extension agreement checks now run
137
+ before any body bytes are read (previously the body was downloaded first and
138
+ rejected afterwards), and the first bytes of the body must be compatible
139
+ with the claimed format's magic bytes — an obviously mislabeled body is
140
+ dropped after the first chunk instead of being downloaded to `max_bytes`.
141
+ - **Remote metadata helpers download only what the answer needs.**
142
+ `remote_size`, `remote_type`, `remote_info` and `remote_animated?` now probe
143
+ the partially-downloaded file at growing thresholds (64KB, 256KB, ...) and
144
+ abort the transfer once the answer is final, instead of always downloading
145
+ up to `max_bytes`. Early answers are only trusted when more data cannot
146
+ change them: "not animated" still requires the complete file (truncated
147
+ animations undercount frames), SVG metadata still downloads the whole
148
+ document so the SVG size cap keeps its meaning, and any prefix probe
149
+ failure falls back to the full download with unchanged validation and
150
+ error behaviour. `fetch_remote` and `remote_dominant_color` still download
151
+ the complete body.
152
+
153
+ ### Fixed
154
+
155
+ - **Lossy PNG optimisation no longer fails when pngquant declines to
156
+ quantise.** pngquant signals "quantised result not used" through exit
157
+ status 98 (`--skip-if-larger`) and 99 (`--quality` not met) — for example
158
+ on low-bit-depth grayscale PNGs its RGBA-palette output cannot beat —
159
+ and `optimize(mode: :lossy)` raised `CommandError` instead of keeping the
160
+ original and continuing to oxipng. Found by running 10 random Wikimedia
161
+ Commons images through the optimizer.
162
+
163
+ - **`optimize` no longer ships sideways JPEGs.** With `strip_metadata: true`
164
+ (the default), stripping deleted the EXIF orientation tag without applying
165
+ the rotation, so an oriented camera photo came out rendered 90/180° wrong.
166
+ `optimize` now bakes the rotation into the pixels first via jpegtran's
167
+ lossless transforms: `-perfect` when the dimensions are MCU-aligned, else
168
+ `-trim`, which drops the partial edge blocks (under one MCU, at most 15px)
169
+ instead of hiding a lossy re-encode. The result hash gains `rotated_from:`
170
+ and `trimmed:` so the trim is reported, never silent — image_optim's jhead
171
+ worker (Discourse's `FileHelper.optimize_image!`) does the same transform
172
+ but trims silently. Without jpegtran an oriented JPEG raises in strict mode
173
+ and is left untouched otherwise; reading the tag goes through the configured
174
+ backend, so `optimize` now also enforces the pixel cap before touching an
175
+ oriented JPEG. Internal callers optimising output the gem just encoded skip
176
+ the check (`assume_upright:`).
177
+
178
+ - **JPEGs with an EXIF orientation no longer fail on the libvips path once
179
+ they outgrow the sequential readahead window (~512px — every real camera
180
+ photo).** `resize`, `crop`, `convert`/`convert_to_jpeg` and the
181
+ `fix_orientation` re-encode tier loaded input with `access: sequential` and
182
+ then autorotated, and the rotation's out-of-order row reads raised
183
+ `VipsJpeg: out of order read`. Oriented images are now reloaded with random
184
+ access before autorotation; upright images keep the streaming sequential
185
+ load, and the pixel cap still runs before any decode.
186
+
187
+ ### Security
188
+
189
+ - **Presentation-attribute `url()` references fail closed unless they are a
190
+ canonical same-document fragment.** A single validation/rewrite grammar now
191
+ governs both the keep decision and the namespace rewrite, so external URLs,
192
+ mismatched quotes, and unterminated forms (`url(#id`, `url(http://evil`) are
193
+ dropped rather than kept on browser parse-error leniency — and no bare,
194
+ un-namespaced reference can survive in inline (`id_namespace:` String) output.
195
+ - **Attribute values containing CSS escapes are rejected outright.**
196
+ Browsers feed SVG presentation attributes through their CSS value parsers,
197
+ where an escape can re-form a token after the sanitizer's pattern checks
198
+ (`ur\6c(...)` is `url(...)`). No allowlisted attribute legitimately
199
+ contains a backslash, so any attribute value with one is now dropped.
200
+ - **SVG parsing rejects encodings the byte-level guards cannot see through.**
201
+ The DOCTYPE/processing-instruction guards are ASCII byte scans; a UTF-16
202
+ document interleaves NUL bytes between the ASCII characters, so a
203
+ `<!DOCTYPE` (and an entity payload behind it) could slip past them while
204
+ REXML still decoded and honoured it. SVG documents must now be UTF-8 (BOM
205
+ allowed) or declare a single-byte ASCII-transparent charset (US-ASCII,
206
+ ISO-8859-*, Windows-125x): UTF-16/32 BOMs, embedded NUL bytes, and declared
207
+ multi-byte or transforming encodings (Shift_JIS, GBK, EUC-*, ISO-2022-*,
208
+ UTF-7) raise `InvalidImageError`. Declared names that fit the allowed shape
209
+ but resolve to no real encoding (e.g. `utf8`, `windows-1259`) also fail
210
+ closed as `InvalidImageError` instead of surfacing REXML's bare
211
+ `ArgumentError`.
212
+
8
213
  ## [0.2.0] - 2026-06-10
9
214
 
10
215
  The host's whole image-processing posture is now decided in one place, once,
@@ -91,18 +296,15 @@ between backends.
91
296
  - Graceful operation without libvips: the new `SafeImage::VipsUnavailableError`
92
297
  (a subclass of `UnsupportedFormatError`) makes `backend: :auto` route through
93
298
  ImageMagick on hosts without the library, while explicit `backend: :vips`
94
- calls fail closed. `SafeImage::VipsGlue.available?` reports the state.
299
+ calls fail closed. `SafeImage::Native.available?` reports the state.
95
300
  - `docker/run.sh`: containerised validation against Debian bookworm's packaged
96
- libvips 8.14 with no toolchain installed.
301
+ libvips 8.14 and native-helper build/install.
97
302
 
98
303
  ### Changed
99
304
 
100
- - **The compiled C extension is gone.** libvips is now bound at runtime
101
- through a minimal Fiddle binding (`SafeImage::VipsGlue`) that exposes only
102
- the operations the gem invokes. Nothing compiles at gem install time; the
103
- only gem dependencies are `fiddle` and `rexml`. Minimum libvips is 8.13
104
- (Debian bookworm's 8.14 package is tested); `SAFE_IMAGE_LIBVIPS` overrides
105
- the library name.
305
+ - **The compiled C helper owns libvips execution.** libvips is initialized in
306
+ the bundled helper process, which exposes only the operations the gem invokes.
307
+ Minimum libvips is 8.13 (Debian bookworm's 8.14 package is tested).
106
308
  - All transform defaults are now native-first: `resize`, `crop`, `downsize`,
107
309
  `convert` and `thumbnail` default to `backend: :auto` (previously
108
310
  ImageMagick for the first three and `:vips` fail-closed for `thumbnail`).
@@ -144,8 +346,8 @@ between backends.
144
346
  untrusted-operation block: JPEG XL is part of the supported input surface,
145
347
  and inputs still pass extension routing, pixel caps and (optionally) the
146
348
  Landlock sandbox.
147
- - The Fiddle binding doubles as an operation allowlist, and a leak-loop test
148
- guards GObject reference handling.
349
+ - The native helper doubles as an operation allowlist, and a leak-loop test
350
+ guards helper request handling.
149
351
 
150
352
  ## [0.1.0] - 2026-06-09
151
353