safe_image 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -12
- data/README.md +140 -287
- data/SECURITY.md +22 -11
- data/docs/architecture.md +63 -0
- data/ext/safe_image_vips_helper/extconf.rb +43 -0
- data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
- data/lib/safe_image/api/metadata.rb +85 -0
- data/lib/safe_image/api/transform.rb +152 -0
- data/lib/safe_image/backend_label.rb +24 -0
- data/lib/safe_image/formats.rb +96 -0
- data/lib/safe_image/ico.rb +42 -40
- data/lib/safe_image/image_magick_backend.rb +219 -162
- data/lib/safe_image/jpegli_backend.rb +64 -44
- data/lib/safe_image/metadata_operations.rb +155 -0
- data/lib/safe_image/native.rb +96 -290
- data/lib/safe_image/native_helper.rb +281 -0
- data/lib/safe_image/operation_backends/base.rb +83 -0
- data/lib/safe_image/operation_backends/image_magick.rb +123 -0
- data/lib/safe_image/operation_backends/vips.rb +251 -0
- data/lib/safe_image/operation_backends.rb +27 -0
- data/lib/safe_image/operation_set.rb +22 -0
- data/lib/safe_image/optimizer.rb +225 -98
- data/lib/safe_image/path_safety.rb +11 -0
- data/lib/safe_image/processor.rb +122 -85
- data/lib/safe_image/quality_defaults.rb +23 -0
- data/lib/safe_image/remote.rb +248 -144
- data/lib/safe_image/result.rb +71 -23
- data/lib/safe_image/runner.rb +60 -23
- data/lib/safe_image/sandbox.rb +44 -218
- data/lib/safe_image/staged_output.rb +44 -0
- data/lib/safe_image/svg_metadata.rb +74 -69
- data/lib/safe_image/transform_operations.rb +139 -0
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/vips_backend.rb +13 -12
- data/lib/safe_image.rb +62 -306
- metadata +43 -37
- data/lib/safe_image/discourse_compat.rb +0 -441
- data/lib/safe_image/svg_css.rb +0 -314
- data/lib/safe_image/svg_sanitizer.rb +0 -583
- data/lib/safe_image/vips_glue.rb +0 -361
- data/lib/safe_image/zygote.rb +0 -619
data/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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
|
|
6
|
+
cropping, converting, optimising, SVG metadata probing, animation checks, dominant
|
|
7
7
|
colour extraction, favicon conversion, and letter-avatar generation.
|
|
8
8
|
Everything that varies by host is decided once, at boot, with a single
|
|
9
9
|
mandatory call:
|
|
@@ -12,27 +12,27 @@ mandatory call:
|
|
|
12
12
|
SafeImage.configure!(backend: :vips, landlock: true)
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
The `:vips` backend uses a tiny
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
The `:vips` backend uses a tiny compiled helper for all libvips work; the Ruby
|
|
16
|
+
process never loads libvips. The `:imagemagick` backend runs ImageMagick with
|
|
17
|
+
shell-free command execution and a restrictive bundled policy. There are no
|
|
18
|
+
per-call backend choices and no silent fallback from one backend to the other:
|
|
19
|
+
you pick the decoder for untrusted bytes in one place and every operation uses
|
|
20
|
+
it.
|
|
21
21
|
|
|
22
22
|
The premise is that hostile image bytes are a lousy thing to spread across
|
|
23
23
|
model callbacks, upload helpers, optimizer wrappers, and hand-built command
|
|
24
24
|
strings. Safe Image puts the risky operations behind one small, hardened
|
|
25
|
-
choke point instead.
|
|
25
|
+
choke point instead. See [`docs/architecture.md`](docs/architecture.md) for the
|
|
26
|
+
security model and the invariants future changes must preserve.
|
|
26
27
|
|
|
27
28
|
## Install
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
[dependencies](#dependencies) below.
|
|
30
|
+
A small native helper is compiled at gem install time and linked against
|
|
31
|
+
libvips; install the libvips development package (`pkg-config vips` must work)
|
|
32
|
+
in the build environment. At runtime, `configure!(backend: :vips)` verifies that
|
|
33
|
+
this helper can start and initialize libvips; all subsequent libvips operations
|
|
34
|
+
run in helper subprocesses whose stderr is captured and reported only on
|
|
35
|
+
structured failures. Install the runtime [dependencies](#dependencies) below.
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
38
|
gem build safe_image.gemspec
|
|
@@ -55,8 +55,8 @@ result = SafeImage.thumbnail(
|
|
|
55
55
|
puts "#{result.backend}: #{result.width}x#{result.height} #{result.filesize} bytes"
|
|
56
56
|
|
|
57
57
|
SafeImage.convert(
|
|
58
|
-
"upload.png",
|
|
59
|
-
"upload.jpg",
|
|
58
|
+
input: "upload.png",
|
|
59
|
+
output: "upload.jpg",
|
|
60
60
|
format: "jpg",
|
|
61
61
|
quality: 85,
|
|
62
62
|
max_pixels: 40_000_000
|
|
@@ -66,13 +66,13 @@ SafeImage.convert(
|
|
|
66
66
|
## Configuration
|
|
67
67
|
|
|
68
68
|
`SafeImage.configure!` must be called before any operation — typically from a
|
|
69
|
-
boot-time initializer. Any operation before it (including
|
|
70
|
-
|
|
69
|
+
boot-time initializer. Any operation before it (including SVG metadata and remote
|
|
70
|
+
helpers) raises `SafeImage::NotConfiguredError`.
|
|
71
71
|
|
|
72
72
|
```ruby
|
|
73
73
|
SafeImage.configure!(
|
|
74
74
|
backend: :vips, # required: :vips or :imagemagick — decodes all untrusted bytes
|
|
75
|
-
landlock: true, # required:
|
|
75
|
+
landlock: true, # required: Landlock child helpers/tools when they run
|
|
76
76
|
max_pixels: SafeImage::DEFAULT_MAX_PIXELS # optional: default decompression-bomb ceiling (128MP)
|
|
77
77
|
)
|
|
78
78
|
```
|
|
@@ -80,7 +80,7 @@ SafeImage.configure!(
|
|
|
80
80
|
Validation is eager, so a misconfigured host fails at boot rather than on the
|
|
81
81
|
first request:
|
|
82
82
|
|
|
83
|
-
- `backend: :vips`
|
|
83
|
+
- `backend: :vips` verifies that the compiled helper can initialize libvips and raises if it is unavailable
|
|
84
84
|
- `backend: :imagemagick` raises if no `magick`/`convert` executable is found
|
|
85
85
|
- `landlock: true` raises if the Landlock sandbox is unavailable
|
|
86
86
|
(`SafeImage.sandbox_available?` works before `configure!`, so a host can
|
|
@@ -98,8 +98,8 @@ is decided here and only here.
|
|
|
98
98
|
|
|
99
99
|
Choosing a backend:
|
|
100
100
|
|
|
101
|
-
- `:vips` — the recommended fast path: explicit native loaders
|
|
102
|
-
|
|
101
|
+
- `:vips` — the recommended fast path: explicit native loaders in the compiled
|
|
102
|
+
helper process, with no libvips loaded into Ruby
|
|
103
103
|
- `:imagemagick` — matches classic ImageMagick `convert` pipelines; also the
|
|
104
104
|
option for hosts without libvips. Formats are decoded by ImageMagick under
|
|
105
105
|
the bundled restrictive policy.
|
|
@@ -116,7 +116,7 @@ Debian / Ubuntu:
|
|
|
116
116
|
|
|
117
117
|
```bash
|
|
118
118
|
sudo apt-get install --no-install-recommends \
|
|
119
|
-
|
|
119
|
+
build-essential pkg-config libvips-dev imagemagick jpegoptim pngquant oxipng libjpeg-turbo-progs
|
|
120
120
|
```
|
|
121
121
|
|
|
122
122
|
`oxipng` is packaged from Debian 13 / Ubuntu 24.04; on older releases install
|
|
@@ -127,7 +127,7 @@ detected at runtime.
|
|
|
127
127
|
Arch:
|
|
128
128
|
|
|
129
129
|
```bash
|
|
130
|
-
sudo pacman -S --needed libvips \
|
|
130
|
+
sudo pacman -S --needed base-devel pkgconf libvips \
|
|
131
131
|
imagemagick jpegoptim pngquant oxipng libjpeg-turbo libjxl
|
|
132
132
|
```
|
|
133
133
|
|
|
@@ -137,18 +137,19 @@ sudo pacman -S --needed libvips \
|
|
|
137
137
|
|
|
138
138
|
| Dependency | Kind | Needed for | Without it |
|
|
139
139
|
| --- | --- | --- | --- |
|
|
140
|
-
| `libvips` runtime library (`libvips.so.42`; Debian: `libvips42` ≥ 8.13) | required for `backend: :vips` | the fast path for every operation
|
|
140
|
+
| `libvips` runtime library (`libvips.so.42`; Debian: `libvips42` ≥ 8.13) plus development headers/pkg-config at gem build time | required for `backend: :vips` | the fast path for every operation and the compiled helper | gem install fails without headers; `configure!(backend: :vips)` raises at boot if the runtime library is unavailable |
|
|
141
141
|
| ImageMagick (`magick`/`convert`, `identify`) | required for `backend: :imagemagick` | every operation on the `:imagemagick` backend | `configure!(backend: :imagemagick)` raises at boot |
|
|
142
142
|
| `jpegoptim` | required for JPEG `optimize` | lossless JPEG optimisation and metadata stripping | JPEG `optimize` raises in strict mode |
|
|
143
143
|
| `oxipng` | required for PNG `optimize` | lossless PNG optimisation | PNG `optimize` raises in strict mode |
|
|
144
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 (
|
|
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 (or copies source to output with `strict: false`) |
|
|
146
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` |
|
|
148
|
-
| `
|
|
147
|
+
| `landlock` gem ≥ 0.3 (Linux kernel ≥ 5.13) | required for `landlock: true` | optional sandboxing for child helpers/tools (`safe_image_vips_helper`, ImageMagick, optimizers, jpegtran/cjpegli) | `configure!(landlock: true)` raises at boot; `sandbox_available?` is false |
|
|
148
|
+
| `nokogiri` gem | automatic | bounded SVG metadata parser (not Landlock-contained) | installed as a gem dependency |
|
|
149
149
|
|
|
150
|
-
The `landlock` gem is intentionally **not** a gem dependency; add
|
|
151
|
-
host application's Gemfile if you want
|
|
150
|
+
The `landlock` gem is intentionally **not** a runtime gem dependency; add
|
|
151
|
+
`gem "landlock", ">= 0.3"` to the host application's Gemfile if you want
|
|
152
|
+
sandboxing.
|
|
152
153
|
|
|
153
154
|
### libvips build capabilities
|
|
154
155
|
|
|
@@ -190,7 +191,7 @@ require "safe_image"
|
|
|
190
191
|
%w[magick identify jpegoptim oxipng pngquant jpegtran cjpegli].each do |tool|
|
|
191
192
|
puts format("%-10s %s", tool, SafeImage::Runner.available?(tool) ? "ok" : "missing")
|
|
192
193
|
end
|
|
193
|
-
puts format("%-10s %s", "
|
|
194
|
+
puts format("%-10s %s", "vips-helper", SafeImage::Native.available? ? "ok" : "unavailable")
|
|
194
195
|
puts format("%-10s %s", "sandbox", SafeImage.sandbox_available? ? "ok" : "unavailable")
|
|
195
196
|
```
|
|
196
197
|
|
|
@@ -213,8 +214,8 @@ SafeImage::Result[
|
|
|
213
214
|
width:,
|
|
214
215
|
height:,
|
|
215
216
|
filesize:,
|
|
216
|
-
backend:, # e.g. "libvips-
|
|
217
|
-
# "libvips-
|
|
217
|
+
backend:, # e.g. "libvips-helper", "imagemagick", "cjpegli",
|
|
218
|
+
# "libvips-helper+cjpegli"
|
|
218
219
|
duration_ms:,
|
|
219
220
|
optimizer: # optimizer tool list for thumbnail path, otherwise nil
|
|
220
221
|
]
|
|
@@ -291,7 +292,9 @@ SafeImage.size("icon.svg") # => [120, 80]
|
|
|
291
292
|
SVG metadata is handled by a dedicated parser, not ImageMagick or libvips. It is
|
|
292
293
|
limited to local `.svg` files, caps input size/tree depth/element/attribute
|
|
293
294
|
counts, rejects `DOCTYPE` and non-XML processing instructions, requires an `<svg>`
|
|
294
|
-
root, and derives dimensions from numeric `width`/`height` or `viewBox`.
|
|
295
|
+
root, and derives dimensions from numeric `width`/`height` or `viewBox`. This
|
|
296
|
+
bounded Nokogiri/libxml2 parse happens in the Ruby process; `landlock: true`
|
|
297
|
+
contains child image helpers/tools, not SVG metadata parsing itself.
|
|
295
298
|
|
|
296
299
|
#### `SafeImage.orientation(path, max_pixels: nil)`
|
|
297
300
|
|
|
@@ -486,15 +489,20 @@ SafeImage.fetch_remote("https://example.com/image.jpg", max_bytes: 10.megabytes)
|
|
|
486
489
|
end
|
|
487
490
|
```
|
|
488
491
|
|
|
489
|
-
With `landlock: true` configured, the network fetch itself is not
|
|
490
|
-
|
|
491
|
-
tempfile is then passed through the normal Safe
|
|
492
|
-
decoding
|
|
492
|
+
With `landlock: true` configured, the network fetch itself is not sandboxed: it
|
|
493
|
+
needs network access, while the image-processing child helpers/tools deliberately
|
|
494
|
+
do not get it. The downloaded tempfile is then passed through the normal Safe
|
|
495
|
+
Image local APIs, so raster decoding and transform subprocesses still use the
|
|
496
|
+
configured Landlock containment.
|
|
493
497
|
|
|
494
498
|
### Generating images
|
|
495
499
|
|
|
496
500
|
Operations that write a new image. All of them run on the configured backend
|
|
497
|
-
and return [`SafeImage::Result`](#return-values).
|
|
501
|
+
and return [`SafeImage::Result`](#return-values). Every operation that reads an
|
|
502
|
+
existing image and writes a file requires explicit `input:` and `output:` paths;
|
|
503
|
+
they must be distinct. Safe Image does not expose in-place mutating APIs — if a
|
|
504
|
+
caller wants to replace a file, write to a separate destination and perform the
|
|
505
|
+
rename in application code.
|
|
498
506
|
|
|
499
507
|
#### `SafeImage.thumbnail(...)`
|
|
500
508
|
|
|
@@ -524,46 +532,46 @@ Supported outputs on the `:vips` backend:
|
|
|
524
532
|
- `avif`
|
|
525
533
|
- `jxl` (requires a libvips build with libjxl support)
|
|
526
534
|
|
|
527
|
-
#### `SafeImage.resize(
|
|
535
|
+
#### `SafeImage.resize(input:, output:, width:, height:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
|
|
528
536
|
|
|
529
537
|
Creates a resized thumbnail-style output.
|
|
530
538
|
|
|
531
539
|
```ruby
|
|
532
|
-
SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400)
|
|
533
|
-
SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400, quality: 85)
|
|
540
|
+
SafeImage.resize(input: "upload.jpg", output: "thumb.jpg", width: 600, height: 400)
|
|
541
|
+
SafeImage.resize(input: "upload.jpg", output: "thumb.jpg", width: 600, height: 400, quality: 85)
|
|
534
542
|
```
|
|
535
543
|
|
|
536
544
|
`resize`, `crop` and `downsize` run on the configured backend:
|
|
537
545
|
|
|
538
|
-
- `:vips` — the
|
|
546
|
+
- `:vips` — the helper-backed libvips path; formats outside the native loader
|
|
539
547
|
allowlist (ICO input) fail closed
|
|
540
548
|
- `:imagemagick` — matches classic `convert` thumbnail pipelines
|
|
541
549
|
(`-thumbnail`, catrom interpolation, unsharp, sRGB profile); configure this
|
|
542
550
|
if byte-similar output with previously generated thumbnails matters
|
|
543
551
|
|
|
544
|
-
#### `SafeImage.crop(
|
|
552
|
+
#### `SafeImage.crop(input:, output:, width:, height:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
|
|
545
553
|
|
|
546
554
|
Creates a north-cropped image — the shape typically used for square avatar
|
|
547
555
|
crops.
|
|
548
556
|
|
|
549
557
|
```ruby
|
|
550
|
-
SafeImage.crop("upload.jpg", "avatar.jpg", 240, 240)
|
|
558
|
+
SafeImage.crop(input: "upload.jpg", output: "avatar.jpg", width: 240, height: 240)
|
|
551
559
|
```
|
|
552
560
|
|
|
553
|
-
#### `SafeImage.downsize(
|
|
561
|
+
#### `SafeImage.downsize(input:, output:, dimensions:, optimize: true, max_pixels: nil, quality: 85, chroma_subsampling: :auto)`
|
|
554
562
|
|
|
555
563
|
Downsizes an image using ImageMagick-style geometry strings.
|
|
556
564
|
|
|
557
565
|
```ruby
|
|
558
|
-
SafeImage.downsize("large.png", "small.png", "50%")
|
|
559
|
-
SafeImage.downsize("large.png", "small.png", "100x100>")
|
|
560
|
-
SafeImage.downsize("large.png", "small.png", "400000@")
|
|
566
|
+
SafeImage.downsize(input: "large.png", output: "small.png", dimensions: "50%")
|
|
567
|
+
SafeImage.downsize(input: "large.png", output: "small.png", dimensions: "100x100>")
|
|
568
|
+
SafeImage.downsize(input: "large.png", output: "small.png", dimensions: "400000@")
|
|
561
569
|
```
|
|
562
570
|
|
|
563
571
|
The vips backend supports the geometry forms covered by the test suite:
|
|
564
572
|
percentage, bounding box with `>`, and pixel-area cap with `@`.
|
|
565
573
|
|
|
566
|
-
#### `SafeImage.convert(
|
|
574
|
+
#### `SafeImage.convert(input:, output:, format:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
|
|
567
575
|
|
|
568
576
|
Converts an input image to an explicit output `format:`. Unsupported formats
|
|
569
577
|
raise `SafeImage::UnsupportedFormatError`.
|
|
@@ -580,12 +588,12 @@ For PNG-to-JPEG on the `:vips` backend, `cjpegli` is used automatically when
|
|
|
580
588
|
installed (see [JPEG encoding of generated images](#jpeg-encoding-of-generated-images)).
|
|
581
589
|
|
|
582
590
|
```ruby
|
|
583
|
-
SafeImage.convert("upload.png", "upload.jpg", format: "jpg", quality: 85)
|
|
584
|
-
SafeImage.convert("upload.heic", "upload.jpg", format: "jpg", quality: 85)
|
|
585
|
-
SafeImage.convert("upload.jpg", "upload.webp", format: "webp", quality: 85)
|
|
591
|
+
SafeImage.convert(input: "upload.png", output: "upload.jpg", format: "jpg", quality: 85)
|
|
592
|
+
SafeImage.convert(input: "upload.heic", output: "upload.jpg", format: "jpg", quality: 85)
|
|
593
|
+
SafeImage.convert(input: "upload.jpg", output: "upload.webp", format: "webp", quality: 85)
|
|
586
594
|
```
|
|
587
595
|
|
|
588
|
-
#### `SafeImage.fix_orientation(
|
|
596
|
+
#### `SafeImage.fix_orientation(input:, output:, max_pixels: nil, quality: nil)`
|
|
589
597
|
|
|
590
598
|
Bakes the EXIF orientation into the pixels and clears the tag. The `:vips`
|
|
591
599
|
backend tries tiers in order:
|
|
@@ -599,26 +607,23 @@ backend tries tiers in order:
|
|
|
599
607
|
The `:imagemagick` backend uses the previous `-auto-orient` behaviour and
|
|
600
608
|
re-encodes at the input's estimated quality.
|
|
601
609
|
|
|
602
|
-
If `to` is omitted, the file is rewritten in place.
|
|
603
|
-
|
|
604
610
|
```ruby
|
|
605
|
-
SafeImage.fix_orientation("upload.jpg")
|
|
606
|
-
SafeImage.fix_orientation("upload.jpg", "oriented.jpg")
|
|
611
|
+
SafeImage.fix_orientation(input: "upload.jpg", output: "oriented.jpg")
|
|
607
612
|
```
|
|
608
613
|
|
|
609
|
-
#### `SafeImage.convert_favicon_to_png(
|
|
614
|
+
#### `SafeImage.convert_favicon_to_png(input:, output:, optimize: true, max_pixels: nil)`
|
|
610
615
|
|
|
611
616
|
Extracts the largest ICO entry and writes PNG. On the `:vips` backend no
|
|
612
617
|
ImageMagick is involved: the container and legacy DIB payloads (1/4/8/24/32bpp
|
|
613
618
|
BI_RGB plus the AND mask) are parsed in pure Ruby with explicit bounds checks,
|
|
614
|
-
and pixels are encoded through the hardened
|
|
619
|
+
and pixels are encoded through the hardened libvips helper. Embedded PNG
|
|
615
620
|
payloads are re-encoded — never copied through verbatim — and their pixel cap
|
|
616
621
|
is enforced from the IHDR before any decoder runs. On the `:imagemagick`
|
|
617
622
|
backend the conversion runs through ImageMagick's ico decoder under the
|
|
618
623
|
bundled policy.
|
|
619
624
|
|
|
620
625
|
```ruby
|
|
621
|
-
SafeImage.convert_favicon_to_png("favicon.ico", "favicon.png")
|
|
626
|
+
SafeImage.convert_favicon_to_png(input: "favicon.ico", output: "favicon.png")
|
|
622
627
|
```
|
|
623
628
|
|
|
624
629
|
#### `SafeImage.letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")`
|
|
@@ -658,17 +663,18 @@ JPEGs**. This avoids hiding a lossy re-encode behind a method named `optimize`.
|
|
|
658
663
|
|
|
659
664
|
Like the optimizer tools, the optional `cjpegli` encoder is availability
|
|
660
665
|
driven: installed means used, absent means the configured backend encodes.
|
|
661
|
-
There is no encoder knob
|
|
662
|
-
|
|
663
|
-
|
|
666
|
+
There is no encoder knob. Even when cjpegli is used, untrusted inputs are first
|
|
667
|
+
decoded by the configured Safe Image backend with the normal pixel cap; cjpegli
|
|
668
|
+
only receives a PNG generated by Safe Image, so it is not an additional
|
|
669
|
+
untrusted-input decoder.
|
|
664
670
|
|
|
665
671
|
| Operation | Behavior |
|
|
666
672
|
| --- | --- |
|
|
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 |
|
|
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 |
|
|
673
|
+
| `thumbnail` / `resize` / `crop` / `downsize` to JPEG on the `:vips` backend | decode through libvips first, then use `cjpegli` when installed; otherwise normal libvips JPEG output |
|
|
674
|
+
| `convert(input: "input.png", output: "output.jpg", format: "jpg")` on the `:vips` backend | decode the PNG through libvips into a generated PNG first, then use `cjpegli` when installed; otherwise libvips |
|
|
675
|
+
| `convert` from HEIC/WebP/AVIF/GIF/JPEG/JXL to JPEG | decode through the native libvips loaders and encode with libvips; `cjpegli` is not treated as a universal decoder |
|
|
670
676
|
| any operation on the `:imagemagick` backend | ImageMagick encodes; `cjpegli` is never used |
|
|
671
|
-
| `optimize("existing.jpg")` | use `jpegoptim`; never `cjpegli` |
|
|
677
|
+
| `optimize(input: "existing.jpg", output: "optimized.jpg")` | use `jpegoptim`; never `cjpegli` |
|
|
672
678
|
|
|
673
679
|
`cjpegli` output is ordinary browser-compatible JPEG. It is optional because it
|
|
674
680
|
is a system binary, not a Ruby dependency. Safe Image detects it at runtime.
|
|
@@ -676,16 +682,17 @@ is a system binary, not a Ruby dependency. Safe Image detects it at runtime.
|
|
|
676
682
|
`chroma_subsampling: :auto` uses `4:4:4` for PNG-sourced JPEG conversion and
|
|
677
683
|
`4:2:0` otherwise. Pass `"420"`, `"422"`, or `"444"` to force a value.
|
|
678
684
|
|
|
679
|
-
### Optimising
|
|
685
|
+
### Optimising to a destination
|
|
680
686
|
|
|
681
|
-
#### `SafeImage.optimize(
|
|
687
|
+
#### `SafeImage.optimize(input:, output:, mode: :lossless, strip_metadata: true, quality: nil, strict: true)`
|
|
682
688
|
|
|
683
|
-
Optimises an existing JPEG or PNG
|
|
689
|
+
Optimises an existing JPEG or PNG into a separate output path. The source file is
|
|
690
|
+
never modified, and `input:`/`output:` must not refer to the same file.
|
|
684
691
|
|
|
685
692
|
```ruby
|
|
686
|
-
SafeImage.optimize("image.jpg", quality: 85)
|
|
687
|
-
SafeImage.optimize("image.png")
|
|
688
|
-
SafeImage.optimize("image.png", mode: :lossy, quality: "65-90")
|
|
693
|
+
SafeImage.optimize(input: "image.jpg", output: "image.optimized.jpg", quality: 85)
|
|
694
|
+
SafeImage.optimize(input: "image.png", output: "image.optimized.png")
|
|
695
|
+
SafeImage.optimize(input: "image.png", output: "image.lossy.png", mode: :lossy, quality: "65-90")
|
|
689
696
|
```
|
|
690
697
|
|
|
691
698
|
JPEG path:
|
|
@@ -699,8 +706,8 @@ JPEG path:
|
|
|
699
706
|
images rotate exactly (`-perfect`); others drop the partial edge blocks
|
|
700
707
|
(`-trim`, under one MCU — at most 15px), reported as `trimmed: true` in
|
|
701
708
|
the result rather than re-encoding behind a method named `optimize`.
|
|
702
|
-
Without `jpegtran`, an oriented JPEG raises in strict mode and is
|
|
703
|
-
|
|
709
|
+
Without `jpegtran`, an oriented JPEG raises in strict mode and the output is
|
|
710
|
+
not written; with `strict: false`, the original bytes are copied to `output:`.
|
|
704
711
|
|
|
705
712
|
PNG path:
|
|
706
713
|
|
|
@@ -709,138 +716,18 @@ PNG path:
|
|
|
709
716
|
then `oxipng`
|
|
710
717
|
|
|
711
718
|
When `strict: true`, missing optimizer tools raise. When `strict: false`, missing
|
|
712
|
-
optimizer tools are tolerated
|
|
713
|
-
|
|
714
|
-
### SVG sanitising
|
|
719
|
+
optimizer tools are tolerated and the output is still written from the source
|
|
720
|
+
copy when possible.
|
|
715
721
|
|
|
716
|
-
|
|
722
|
+
### SVG handling
|
|
717
723
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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)
|
|
725
|
-
|
|
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}")
|
|
728
|
-
|
|
729
|
-
puts result[:sanitized]
|
|
730
|
-
```
|
|
731
|
-
|
|
732
|
-
Omitting `id_namespace:` (or passing `nil`/`""`) raises `ArgumentError`.
|
|
733
|
-
|
|
734
|
-
The sanitizer removes unsafe elements/attributes such as scripts and event
|
|
735
|
-
handlers. It is intentionally conservative rather than a full browser-grade SVG
|
|
736
|
-
implementation.
|
|
737
|
-
|
|
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.
|
|
765
|
-
|
|
766
|
-
SVG sanitising is defense-in-depth for stored bytes. Applications that serve
|
|
767
|
-
user-supplied SVGs directly should still use response-level controls such as a
|
|
724
|
+
Safe Image no longer sanitises or rewrites SVG files. It only supports bounded
|
|
725
|
+
metadata probing for local and remote `.svg` inputs (`type`, `size`, `info`, and
|
|
726
|
+
the corresponding remote helpers). Applications that accept user-supplied SVG
|
|
727
|
+
content for display should run a dedicated SVG sanitizer in their own upload or
|
|
728
|
+
rendering pipeline, and should still use response-level controls such as a
|
|
768
729
|
restrictive `Content-Security-Policy`, `X-Content-Type-Options: nosniff`, and/or
|
|
769
|
-
attachment/sandbox handling for direct-open routes.
|
|
770
|
-
execution when an SVG is embedded as `<img>`, but a top-level SVG document is a
|
|
771
|
-
different sink.
|
|
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.
|
|
730
|
+
attachment/sandbox handling for direct-open routes.
|
|
844
731
|
|
|
845
732
|
## Security
|
|
846
733
|
|
|
@@ -857,12 +744,12 @@ What it does:
|
|
|
857
744
|
- starts external commands with an allowlisted environment, private temp/home/cache
|
|
858
745
|
directories, bounded stdout/stderr, and process-group timeout cleanup
|
|
859
746
|
- uses explicit libvips loaders selected from allowlisted extensions
|
|
860
|
-
- enables libvips' untrusted-operation block
|
|
747
|
+
- enables libvips' untrusted-operation block inside the helper (deliberately
|
|
861
748
|
re-enabling only the libjxl loader/saver, which libvips tags untrusted,
|
|
862
749
|
because JPEG XL is part of the supported input surface)
|
|
863
|
-
- blocks libvips ImageMagick loader classes in the
|
|
864
|
-
|
|
865
|
-
- disables libvips cache
|
|
750
|
+
- blocks libvips ImageMagick loader classes in the helper, which exposes only
|
|
751
|
+
the operations the gem invokes
|
|
752
|
+
- disables libvips cache inside the helper
|
|
866
753
|
- strips metadata on generated images where applicable
|
|
867
754
|
- rejects symlinked local input/output paths and symlinked path components for
|
|
868
755
|
untrusted file-processing paths
|
|
@@ -879,12 +766,8 @@ What it does:
|
|
|
879
766
|
blocking, DNS pinning, redirect limits, HTTPS-to-HTTP rejection, proxy-env
|
|
880
767
|
bypass prevention, request-header allowlists, content-type/extension
|
|
881
768
|
agreement, and probe-before-yield (details under [Remote URLs](#remote-urls))
|
|
882
|
-
- parses SVG metadata with a bounded
|
|
769
|
+
- parses SVG metadata with a bounded Nokogiri SAX parser; SVG is never handed to
|
|
883
770
|
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
771
|
- supports optional Landlock subprocess sandboxing on Linux
|
|
889
772
|
|
|
890
773
|
The backend is a configuration decision, not a per-call option. Safe Image
|
|
@@ -907,23 +790,23 @@ ImageMagick path runs with:
|
|
|
907
790
|
|
|
908
791
|
That does **not** make hostile files benign. Raster decoders still parse attacker
|
|
909
792
|
controlled bytes: libjpeg, libpng, libwebp, libheif/HEIC/AVIF, ImageMagick's
|
|
910
|
-
raster decoders, and libvips loaders
|
|
911
|
-
|
|
912
|
-
sandbox.
|
|
793
|
+
raster decoders, and libvips loaders; Nokogiri/libxml2 also parses SVG metadata.
|
|
794
|
+
If one of those parsers or decoders has a memory corruption bug or pathological
|
|
795
|
+
resource-consumption bug, policy alone is not a sandbox.
|
|
913
796
|
|
|
914
797
|
So the intended posture is:
|
|
915
798
|
|
|
916
799
|
- without Landlock: hardened, centralized image processing with the major
|
|
917
800
|
ImageMagick delegate/pseudo-protocol foot-guns removed
|
|
918
|
-
- with Landlock: the same hardening plus a
|
|
919
|
-
|
|
801
|
+
- with Landlock: the same hardening plus a containment boundary around child
|
|
802
|
+
helpers/tools that parse or transform raster bytes
|
|
920
803
|
|
|
921
804
|
Do not describe non-sandboxed operation as making hostile images safe. The honest
|
|
922
805
|
claim is defense-in-depth, not immunity.
|
|
923
806
|
|
|
924
|
-
###
|
|
807
|
+
### Optional Landlock containment
|
|
925
808
|
|
|
926
|
-
Landlock support is optional, but
|
|
809
|
+
Landlock support is optional, but fail-closed once configured.
|
|
927
810
|
|
|
928
811
|
```ruby
|
|
929
812
|
SafeImage.sandbox_available? # => true/false, works before configure!
|
|
@@ -931,74 +814,44 @@ SafeImage.configure!(backend: :vips, landlock: true) # raises if unavailable
|
|
|
931
814
|
SafeImage.config.landlock # => true
|
|
932
815
|
```
|
|
933
816
|
|
|
934
|
-
With `landlock: true`,
|
|
935
|
-
worker:
|
|
936
|
-
|
|
937
|
-
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
- `
|
|
943
|
-
- `
|
|
944
|
-
- `
|
|
945
|
-
- `
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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.
|
|
817
|
+
With `landlock: true`, Safe Image does **not** proxy public operations through a
|
|
818
|
+
sandboxed Ruby worker. Ruby remains the orchestrator: it validates paths, stages
|
|
819
|
+
outputs, applies format/pixel policy, parses bounded SVG/ICO metadata where
|
|
820
|
+
needed, and launches the actual image-processing helpers/tools.
|
|
821
|
+
|
|
822
|
+
The Landlock boundary is applied to child processes that do risky native image
|
|
823
|
+
work:
|
|
824
|
+
|
|
825
|
+
- `safe_image_vips_helper` for every libvips operation
|
|
826
|
+
- ImageMagick `magick`/`convert`/`identify` commands on the `:imagemagick` backend
|
|
827
|
+
- optimizer tools such as `jpegoptim`, `oxipng`, and `pngquant`
|
|
828
|
+
- `jpegtran` and `cjpegli` when those optional paths are used
|
|
829
|
+
|
|
830
|
+
There is no silent fallback once Landlock is configured. If sandbox setup or a
|
|
831
|
+
sandboxed helper/tool fails, the operation fails.
|
|
832
|
+
|
|
833
|
+
Each child process receives only the explicit read/write grants for that call,
|
|
834
|
+
plus runtime/library/font/temp paths needed by the helper or tool. The network is
|
|
835
|
+
denied for those children; remote fetching happens in Ruby before the downloaded
|
|
836
|
+
tempfile is handed back to the local image APIs.
|
|
837
|
+
|
|
838
|
+
For the `:vips` backend, raster operations are executed by the compiled
|
|
839
|
+
`safe_image_vips_helper` executable. The Ruby process performs path validation and
|
|
840
|
+
orchestration, then the helper initializes libvips in its own process, applies the
|
|
841
|
+
loader/operation allowlist and pixel cap, writes a structured JSON response, and
|
|
842
|
+
exits. With Landlock enabled, the helper process itself is launched inside the
|
|
843
|
+
per-call filesystem policy.
|
|
844
|
+
|
|
845
|
+
SVG metadata is the deliberate exception: it is bounded and non-rendering, but it
|
|
846
|
+
is parsed by Nokogiri/libxml2 in the Ruby process and is not Landlock-contained by
|
|
847
|
+
Safe Image. If your deployment needs a hard kernel boundary around SVG parsing as
|
|
848
|
+
well, run Safe Image in a separate application process/container.
|
|
996
849
|
|
|
997
850
|
## Development
|
|
998
851
|
|
|
999
852
|
```bash
|
|
1000
853
|
bundle install
|
|
1001
|
-
bundle exec rake # run the tests (
|
|
854
|
+
bundle exec rake # run the tests (builds/uses the native vips helper)
|
|
1002
855
|
docker/run.sh # run the suite on Debian bookworm's packaged libvips 8.14
|
|
1003
856
|
bundle exec rubocop # lint
|
|
1004
857
|
```
|
|
@@ -1010,8 +863,8 @@ explanation when that support is missing.
|
|
|
1010
863
|
|
|
1011
864
|
The suite includes golden-output checks, cross-backend parity checks (the
|
|
1012
865
|
claim is operation parity, not byte-for-byte output identity across
|
|
1013
|
-
ImageMagick/libvips versions), policy-denial checks, and
|
|
1014
|
-
|
|
866
|
+
ImageMagick/libvips versions), policy-denial checks, and Landlock sweeps over
|
|
867
|
+
helper/tool execution paths.
|
|
1015
868
|
|
|
1016
869
|
Gem packaging uses the standard Bundler tasks (`rake build`, `rake install`).
|
|
1017
870
|
Releases: bump `SafeImage::VERSION`, merge to `main`, and CI publishes the
|