brotli_splice 0.1.1 → 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: '0810be973e8e1ad5dd1b28a4733754bd2ec87f6e5d5c5c3e6a29d44589e505ab'
4
- data.tar.gz: cc459d36cbf640e4fb47d426dbce7dcb68f4458cf6daf49080e7fa9d67d5e0bd
3
+ metadata.gz: ce9fb6c98f2b365b24bad75eb7f1ae7bfef2f86c025a87f27e04d5bf5f8732ee
4
+ data.tar.gz: 5b98a2d69c15302930662ea35ce53b1a987a180e388473083d1c2f89dc59f532
5
5
  SHA512:
6
- metadata.gz: 8af6e37050b29d440cf25ae1f97ea502d2dffa513516e8592216fd16664a3a68c8796121704dde5f10618eea4353ba39a9a117820f35de34a281a24775913b72
7
- data.tar.gz: 5742e2a3183a99e2965d8dd2e8dbe3e3f93c8c7ea1cbe6e2bcfb065bf3ab82c5eb88b7f32590a4f36b98a6b18bf17025fa14600260e2334b2b797e1c0ece604f
6
+ metadata.gz: 122de9b45887622ec860896ea6a7b5ad263f2c0ad5ed78296abc7e53beeaae019b740c86d33c6a88337692fc5bdcacc3673d2ea3b63fdd02e1b98a444daa6a5a
7
+ data.tar.gz: c5a7e0fc26794e77b74fd412c9c26a2350500d1529f90b93fd3add154b2145d231491864c35bf0a1ae0091b3a09a76ae53ee833736d5583df6d2c4d73245823f
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # BrotliSplice
2
2
 
3
- A Ruby C extension that produces Brotli-compressed streams with a fixed-length
4
- **spliceable slot** — a region whose raw bytes can be overwritten without
3
+ A Ruby C extension that produces Brotli-compressed streams with fixed-length
4
+ **spliceable slots** — regions whose raw bytes can be overwritten without
5
5
  decompressing or re-encoding the rest of the stream.
6
6
 
7
7
  ## Use case
@@ -33,6 +33,11 @@ The last 2 bytes of the secret slot are reserved as a fixed **context suffix**
33
33
  uses the preceding 2 bytes as literal context when compressing part3. The
34
34
  replaceable portion is therefore `secret_length - 2` bytes.
35
35
 
36
+ A stream can hold several slots. Each slot repeats the same layout: the encoder
37
+ flushes, writes the slot as an uncompressed meta-block, and continues with a
38
+ fresh encoder at the new stream offset. Every slot has its own context suffix
39
+ and adds its own share of the overhead described below.
40
+
36
41
  Compression overhead vs standard Brotli is small — typically 100–200 bytes
37
42
  for realistic HTML documents. Two sources of overhead:
38
43
 
@@ -99,6 +104,24 @@ ruby -Ilib test_ext.rb
99
104
 
100
105
  ## API
101
106
 
107
+ ### `BrotliSplice::Encoder`
108
+
109
+ Use the stateful encoder when the full document is not available up front:
110
+
111
+ ```ruby
112
+ encoder = BrotliSplice::Encoder.new(quality: 5, lgwin: 22)
113
+ compressed = encoder.write("<html><head>".b)
114
+ compressed << encoder.write("<title>Store</title>".b)
115
+ compressed << encoder.slot("live-token\r\n".b)
116
+ compressed << encoder.finish("</head><body>...</body></html>".b)
117
+ ```
118
+
119
+ `write` flushes and byte-aligns each chunk. `slot` may be called any number of times. Its input must end in the fixed `"\r\n"` context suffix, which is not replaceable. `finish` closes the stream and can include a final chunk. Strings are treated as byte sequences regardless of their Ruby encoding.
120
+
121
+ After `slot`, `slot_offset` and `slot_length` identify the raw replaceable bytes of the most recent slot in the combined compressed output. `slots` lists every slot in stream order as `{ offset:, length: }` hashes. `context_suffix` returns `"\r\n"`.
122
+
123
+ Call `close` when abandoning an unfinished encoder. It releases native state without producing output and is safe to call more than once or after `finish`. Once an encoder is closed, finished, or fails after consuming input, later encoding operations raise `BrotliSplice::Error`.
124
+
102
125
  ### `BrotliSplice.encode(html, secret_offset, secret_length, quality: 11) → Hash`
103
126
 
104
127
  Compress `html` with a spliceable slot at the given byte position.
@@ -119,6 +142,28 @@ Compress `html` with a spliceable slot at the given byte position.
119
142
  }
120
143
  ```
121
144
 
145
+ ### `BrotliSplice.encode_slots(html, slots, quality: 11) → Hash`
146
+
147
+ Compress `html` with a spliceable slot at each given byte range. With one
148
+ range, `data` is identical to the output of `encode`.
149
+
150
+ **Parameters:**
151
+ - `html` — the full HTML string (binary encoding)
152
+ - `slots` — `[offset, length]` byte ranges in ascending order. Ranges may touch
153
+ but must not overlap, and each length must be > 2
154
+ - `quality:` — Brotli quality level, 0–11 (default: 11)
155
+
156
+ **Returns** a Hash:
157
+ ```ruby
158
+ {
159
+ data: String, # the Brotli-compressed stream
160
+ slots: Array, # one { offset:, length: } Hash per range, in order:
161
+ # the replaceable bytes within the compressed stream
162
+ # (length = range length - 2)
163
+ context_suffix: String, # the 2 fixed context bytes ("\r\n")
164
+ }
165
+ ```
166
+
122
167
  ### `BrotliSplice.replace(compressed_data, new_secret, secret_offset, secret_length) → String`
123
168
 
124
169
  Replace the secret in a compressed stream. Returns a new string with the
@@ -130,6 +175,15 @@ swapped bytes. This is a pure `memcpy` — no compression or decompression.
130
175
  - `secret_offset` — from the `encode` result
131
176
  - `secret_length` — from the `encode` result
132
177
 
178
+ ### `BrotliSplice.replace_slots(compressed_data, replacements) → String`
179
+
180
+ Replace several slots while copying `compressed_data` only once.
181
+ `replacements` is an Array of `[new_secret, secret_offset, secret_length]`
182
+ entries, with offsets and lengths from `encode_slots` or `Encoder#slots`.
183
+ Entries must be in ascending offset order and must not overlap, as those methods
184
+ return them; they may touch. Every entry is checked before any byte is copied,
185
+ so an invalid entry raises `BrotliSplice::Error` and returns nothing.
186
+
133
187
  ## Example
134
188
 
135
189
  ```ruby
@@ -167,8 +221,7 @@ longer, or place the suffix where a line break is natural).
167
221
 
168
222
  ## Limitations
169
223
 
170
- - The replaceable slot must be ≤ 65534 bytes (64KB minus the 2-byte context)
224
+ - Each replaceable slot must be no larger than 65536 bytes, plus the 2-byte context suffix
171
225
  - The replacement must be **exactly** `secret_length` bytes (no length changes)
172
- - The last 2 bytes of the original secret region become `\r\n` in the output
173
- - Only one spliceable slot per stream (encode with multiple slots would require
174
- chaining encoders)
226
+ - The last 2 bytes of each original slot region become `\r\n` in the output
227
+ - Slots must not overlap
@@ -8,7 +8,7 @@ Gem::Specification.new do |spec|
8
8
  spec.summary = "Create Brotli streams with fixed-length spliceable slots"
9
9
  spec.description = <<~DESC
10
10
  BrotliSplice is a Ruby C extension that creates Brotli-compressed streams
11
- containing a fixed-length uncompressed slot. The slot can be overwritten
11
+ containing fixed-length uncompressed slots. Each slot can be overwritten
12
12
  with a simple byte copy, making it useful for injecting fixed-size secrets
13
13
  or tokens into pre-compressed HTML responses.
14
14
  DESC
@@ -1,197 +1,671 @@
1
1
  /*
2
2
  * BrotliSplice — Ruby C extension for spliceable Brotli encoding.
3
- *
4
- * Produces a Brotli stream with a fixed-length uncompressed slot whose
5
- * raw bytes can be overwritten without re-encoding the rest.
6
- *
7
- * Uses the standard Brotli streaming API with BROTLI_PARAM_STREAM_OFFSET.
8
3
  */
9
4
 
10
5
  #include <ruby.h>
11
6
  #include <ruby/encoding.h>
12
7
  #include <brotli/encode.h>
13
- #include <brotli/decode.h>
8
+ #include <stddef.h>
9
+ #include <stdint.h>
10
+ #include <stdlib.h>
14
11
  #include <string.h>
15
12
 
16
13
  static VALUE mBrotliSplice;
14
+ static VALUE cEncoder;
17
15
  static VALUE eBrotliSpliceError;
18
16
 
19
17
  #define CTX_LEN 2
18
+ /* An uncompressed meta-block header stores MLEN - 1 in 16 bits. */
19
+ #define MAX_SLOT_BODY_LEN 65536
20
+ #define OUTPUT_CHUNK_SIZE 16384
20
21
  static const uint8_t CTX_BYTES[CTX_LEN] = { '\r', '\n' };
21
22
 
22
- /* ── Streaming encoder helper ─────────────────────────────────── */
23
- static size_t encoder_feed(BrotliEncoderState *s, BrotliEncoderOperation op,
24
- const uint8_t *in, size_t in_len,
25
- uint8_t *out, size_t out_cap) {
26
- size_t avail_in = in_len, avail_out = out_cap, total = 0;
27
- const uint8_t *next_in = in;
28
- uint8_t *next_out = out;
29
- if (!BrotliEncoderCompressStream(s, op,
30
- &avail_in, &next_in, &avail_out, &next_out, &total)) {
31
- return 0;
32
- }
33
- return out_cap - avail_out;
34
- }
35
-
36
- /* ── Build uncompressed meta-block (ISLAST=0) ─────────────────── */
37
- static size_t make_uncompressed_block(uint8_t *out,
38
- const uint8_t *data, size_t len) {
39
- if (len == 0 || len > 65536) return 0;
40
- uint32_t r = (uint32_t)(len - 1);
41
- uint32_t nibbles = 0;
42
- if (len > (1u << 16)) nibbles = (len > (1u << 20)) ? 2 : 1;
43
- uint32_t bits = (nibbles << 1) | (r << 3) | (1u << (19 + 4 * nibbles));
44
- out[0] = (uint8_t)bits;
45
- out[1] = (uint8_t)(bits >> 8);
46
- out[2] = (uint8_t)(bits >> 16);
47
- size_t hdr = 3;
48
- if (nibbles == 2) { out[3] = (uint8_t)(bits >> 24); hdr = 4; }
49
- memcpy(out + hdr, data, len);
50
- return hdr + len;
23
+ typedef enum {
24
+ ENCODER_UNINITIALIZED,
25
+ ENCODER_ACTIVE,
26
+ ENCODER_FINISHED,
27
+ ENCODER_CLOSED,
28
+ ENCODER_FAILED,
29
+ } encoder_lifecycle_t;
30
+
31
+ typedef struct {
32
+ size_t offset;
33
+ size_t length;
34
+ } slot_position_t;
35
+
36
+ typedef struct {
37
+ BrotliEncoderState *encoder;
38
+ int quality;
39
+ int lgwin;
40
+ size_t uncompressed_bytes;
41
+ size_t bytes_written;
42
+ slot_position_t *slots;
43
+ size_t slot_count;
44
+ size_t slot_capacity;
45
+ size_t native_bytes;
46
+ encoder_lifecycle_t lifecycle;
47
+ } splice_encoder_t;
48
+
49
+ typedef union {
50
+ struct {
51
+ size_t allocation_size;
52
+ } tracked;
53
+ max_align_t alignment;
54
+ } allocation_header_t;
55
+
56
+ static void *encoder_alloc_native(void *opaque, size_t size) {
57
+ splice_encoder_t *encoder = opaque;
58
+ if (size > SIZE_MAX - sizeof(allocation_header_t)) return NULL;
59
+
60
+ size_t allocation_size = sizeof(allocation_header_t) + size;
61
+ allocation_header_t *header = malloc(allocation_size);
62
+ if (!header) return NULL;
63
+ if (encoder->native_bytes > SIZE_MAX - allocation_size) {
64
+ free(header);
65
+ return NULL;
66
+ }
67
+
68
+ header->tracked.allocation_size = allocation_size;
69
+ encoder->native_bytes += allocation_size;
70
+ return header + 1;
71
+ }
72
+
73
+ static void encoder_free_native(void *opaque, void *address) {
74
+ if (!address) return;
75
+
76
+ splice_encoder_t *encoder = opaque;
77
+ allocation_header_t *header = ((allocation_header_t *)address) - 1;
78
+ encoder->native_bytes -= header->tracked.allocation_size;
79
+ free(header);
80
+ }
81
+
82
+ static void encoder_destroy_native(splice_encoder_t *encoder) {
83
+ if (!encoder->encoder) return;
84
+
85
+ BrotliEncoderDestroyInstance(encoder->encoder);
86
+ encoder->encoder = NULL;
87
+ }
88
+
89
+ static void encoder_terminalize(splice_encoder_t *encoder, encoder_lifecycle_t lifecycle) {
90
+ encoder_destroy_native(encoder);
91
+ encoder->lifecycle = lifecycle;
92
+ }
93
+
94
+ static void encoder_free(void *pointer) {
95
+ splice_encoder_t *encoder = pointer;
96
+ encoder_destroy_native(encoder);
97
+ xfree(encoder->slots);
98
+ xfree(encoder);
99
+ }
100
+
101
+ static size_t encoder_memsize(const void *pointer) {
102
+ if (!pointer) return 0;
103
+
104
+ const splice_encoder_t *encoder = pointer;
105
+ size_t fixed_size = sizeof(splice_encoder_t) + encoder->slot_capacity * sizeof(slot_position_t);
106
+ if (encoder->native_bytes > SIZE_MAX - fixed_size) return SIZE_MAX;
107
+ return fixed_size + encoder->native_bytes;
108
+ }
109
+
110
+ static const rb_data_type_t encoder_type = {
111
+ "BrotliSplice::Encoder",
112
+ {NULL, encoder_free, encoder_memsize},
113
+ NULL,
114
+ NULL,
115
+ RUBY_TYPED_FREE_IMMEDIATELY,
116
+ };
117
+
118
+ static VALUE encoder_alloc(VALUE klass) {
119
+ splice_encoder_t *encoder;
120
+ VALUE object = TypedData_Make_Struct(klass, splice_encoder_t, &encoder_type, encoder);
121
+ memset(encoder, 0, sizeof(*encoder));
122
+ return object;
123
+ }
124
+
125
+ static BrotliEncoderState *new_encoder(splice_encoder_t *encoder, int quality, int lgwin, uint32_t stream_offset) {
126
+ BrotliEncoderState *state = BrotliEncoderCreateInstance(encoder_alloc_native, encoder_free_native, encoder);
127
+ if (!state) rb_raise(eBrotliSpliceError, "encoder creation failed");
128
+
129
+ if (!BrotliEncoderSetParameter(state, BROTLI_PARAM_QUALITY, (uint32_t)quality) ||
130
+ !BrotliEncoderSetParameter(state, BROTLI_PARAM_LGWIN, (uint32_t)lgwin) ||
131
+ (stream_offset > 0 && !BrotliEncoderSetParameter(state, BROTLI_PARAM_STREAM_OFFSET, stream_offset))) {
132
+ BrotliEncoderDestroyInstance(state);
133
+ rb_raise(eBrotliSpliceError, "invalid encoder parameters");
134
+ }
135
+
136
+ return state;
137
+ }
138
+
139
+ static void check_string(VALUE string) {
140
+ Check_Type(string, T_STRING);
141
+ }
142
+
143
+ static VALUE binary_string(const uint8_t *bytes, size_t length) {
144
+ VALUE result = rb_str_new((const char *)bytes, length);
145
+ rb_enc_associate(result, rb_ascii8bit_encoding());
146
+ return result;
147
+ }
148
+
149
+ static VALUE encoder_feed(BrotliEncoderState *state, BrotliEncoderOperation operation,
150
+ const uint8_t *input, size_t input_length) {
151
+ VALUE output = rb_str_buf_new(64);
152
+ size_t available_in = input_length;
153
+ const uint8_t *next_in = input;
154
+
155
+ for (;;) {
156
+ uint8_t buffer[OUTPUT_CHUNK_SIZE];
157
+ size_t available_out = sizeof(buffer);
158
+ uint8_t *next_out = buffer;
159
+ size_t before_in = available_in;
160
+
161
+ if (!BrotliEncoderCompressStream(
162
+ state,
163
+ operation,
164
+ &available_in,
165
+ &next_in,
166
+ &available_out,
167
+ &next_out,
168
+ NULL)) {
169
+ rb_raise(eBrotliSpliceError, "encoding failed");
170
+ }
171
+
172
+ size_t produced = sizeof(buffer) - available_out;
173
+ if (produced > 0) rb_str_cat(output, (const char *)buffer, produced);
174
+
175
+ if (operation == BROTLI_OPERATION_FINISH) {
176
+ if (BrotliEncoderIsFinished(state)) break;
177
+ } else if (available_in == 0 && !BrotliEncoderHasMoreOutput(state)) {
178
+ break;
179
+ }
180
+
181
+ if (before_in == available_in && produced == 0) {
182
+ rb_raise(eBrotliSpliceError, "encoder made no progress");
183
+ }
184
+ }
185
+
186
+ rb_enc_associate(output, rb_ascii8bit_encoding());
187
+ return output;
188
+ }
189
+
190
+ static void make_uncompressed_header(uint8_t output[3], size_t length) {
191
+ uint32_t value = (uint32_t)(length - 1);
192
+ uint32_t bits = (value << 3) | (1u << 19);
193
+ output[0] = (uint8_t)bits;
194
+ output[1] = (uint8_t)(bits >> 8);
195
+ output[2] = (uint8_t)(bits >> 16);
196
+ }
197
+
198
+ static VALUE encoder_initialize(int argc, VALUE *argv, VALUE self) {
199
+ VALUE options;
200
+ rb_scan_args(argc, argv, "0:", &options);
201
+
202
+ splice_encoder_t *encoder;
203
+ TypedData_Get_Struct(self, splice_encoder_t, &encoder_type, encoder);
204
+
205
+ if (encoder->lifecycle != ENCODER_UNINITIALIZED) {
206
+ rb_raise(eBrotliSpliceError, "encoder is already initialized");
207
+ }
208
+
209
+ int quality_value = 11;
210
+ int lgwin_value = 22;
211
+ if (!NIL_P(options)) {
212
+ VALUE quality = rb_hash_aref(options, ID2SYM(rb_intern("quality")));
213
+ VALUE lgwin = rb_hash_aref(options, ID2SYM(rb_intern("lgwin")));
214
+ if (!NIL_P(quality)) quality_value = NUM2INT(quality);
215
+ if (!NIL_P(lgwin)) lgwin_value = NUM2INT(lgwin);
216
+ }
217
+ if (quality_value < 0 || quality_value > 11) {
218
+ rb_raise(eBrotliSpliceError, "quality must be between 0 and 11");
219
+ }
220
+ if (lgwin_value < 10 || lgwin_value > 24) {
221
+ rb_raise(eBrotliSpliceError, "lgwin must be between 10 and 24");
222
+ }
223
+ if (encoder->lifecycle != ENCODER_UNINITIALIZED) {
224
+ rb_raise(eBrotliSpliceError, "encoder is already initialized");
225
+ }
226
+
227
+ encoder->quality = quality_value;
228
+ encoder->lgwin = lgwin_value;
229
+ encoder->encoder = new_encoder(encoder, encoder->quality, encoder->lgwin, 0);
230
+ encoder->lifecycle = ENCODER_ACTIVE;
231
+ return self;
232
+ }
233
+
234
+ static void ensure_writable(splice_encoder_t *encoder) {
235
+ switch (encoder->lifecycle) {
236
+ case ENCODER_ACTIVE:
237
+ return;
238
+ case ENCODER_FINISHED:
239
+ rb_raise(eBrotliSpliceError, "encoder is finished");
240
+ case ENCODER_CLOSED:
241
+ rb_raise(eBrotliSpliceError, "encoder is closed");
242
+ case ENCODER_FAILED:
243
+ rb_raise(eBrotliSpliceError, "encoder failed");
244
+ case ENCODER_UNINITIALIZED:
245
+ rb_raise(eBrotliSpliceError, "encoder is not initialized");
246
+ }
247
+ }
248
+
249
+ static VALUE encoder_initialize_copy(VALUE self, VALUE original) {
250
+ rb_raise(rb_eTypeError, "BrotliSplice::Encoder cannot be copied");
251
+ }
252
+
253
+ typedef struct {
254
+ splice_encoder_t *encoder;
255
+ VALUE bytes;
256
+ } encoder_operation_t;
257
+
258
+ static VALUE run_encoder_operation(
259
+ splice_encoder_t *encoder,
260
+ VALUE (*operation)(VALUE),
261
+ encoder_operation_t *arguments
262
+ ) {
263
+ int state = 0;
264
+ VALUE output = rb_protect(operation, (VALUE)arguments, &state);
265
+ if (state) {
266
+ encoder_terminalize(encoder, ENCODER_FAILED);
267
+ rb_jump_tag(state);
268
+ }
269
+ return output;
270
+ }
271
+
272
+ static VALUE encoder_write_operation(VALUE opaque) {
273
+ encoder_operation_t *arguments = (encoder_operation_t *)opaque;
274
+ splice_encoder_t *encoder = arguments->encoder;
275
+ VALUE bytes = arguments->bytes;
276
+ VALUE output = encoder_feed(
277
+ encoder->encoder,
278
+ BROTLI_OPERATION_FLUSH,
279
+ (const uint8_t *)RSTRING_PTR(bytes),
280
+ (size_t)RSTRING_LEN(bytes));
281
+ encoder->uncompressed_bytes += (size_t)RSTRING_LEN(bytes);
282
+ encoder->bytes_written += (size_t)RSTRING_LEN(output);
283
+ return output;
284
+ }
285
+
286
+ static VALUE encoder_write(VALUE self, VALUE bytes) {
287
+ splice_encoder_t *encoder;
288
+ TypedData_Get_Struct(self, splice_encoder_t, &encoder_type, encoder);
289
+ ensure_writable(encoder);
290
+ check_string(bytes);
291
+
292
+ encoder_operation_t arguments = {encoder, bytes};
293
+ return run_encoder_operation(encoder, encoder_write_operation, &arguments);
294
+ }
295
+
296
+ static VALUE encoder_slot_operation(VALUE opaque) {
297
+ encoder_operation_t *arguments = (encoder_operation_t *)opaque;
298
+ splice_encoder_t *encoder = arguments->encoder;
299
+ VALUE bytes = arguments->bytes;
300
+ size_t total_length = (size_t)RSTRING_LEN(bytes);
301
+ size_t body_length = total_length - CTX_LEN;
302
+ size_t max_stream_offset = ((size_t)1) << encoder->lgwin;
303
+ uint32_t stream_offset = (uint32_t)(
304
+ encoder->uncompressed_bytes + body_length > max_stream_offset
305
+ ? max_stream_offset
306
+ : encoder->uncompressed_bytes + body_length);
307
+
308
+ VALUE output = encoder_feed(
309
+ encoder->encoder,
310
+ BROTLI_OPERATION_FLUSH,
311
+ NULL,
312
+ 0);
313
+ size_t flushed_length = (size_t)RSTRING_LEN(output);
314
+
315
+ BrotliEncoderState *next_encoder = new_encoder(encoder, encoder->quality, encoder->lgwin, stream_offset);
316
+ encoder_destroy_native(encoder);
317
+ encoder->encoder = next_encoder;
318
+
319
+ uint8_t header[3];
320
+ make_uncompressed_header(header, body_length);
321
+ rb_str_cat(output, (const char *)header, sizeof(header));
322
+ rb_str_cat(output, RSTRING_PTR(bytes), body_length);
323
+
324
+ VALUE context_output = encoder_feed(
325
+ encoder->encoder,
326
+ BROTLI_OPERATION_PROCESS,
327
+ CTX_BYTES,
328
+ CTX_LEN);
329
+ rb_str_concat(output, context_output);
330
+
331
+ encoder->slots[encoder->slot_count].offset = encoder->bytes_written + flushed_length + sizeof(header);
332
+ encoder->slots[encoder->slot_count].length = body_length;
333
+ encoder->slot_count++;
334
+ encoder->bytes_written += flushed_length + sizeof(header) + body_length + (size_t)RSTRING_LEN(context_output);
335
+ encoder->uncompressed_bytes += body_length + CTX_LEN;
336
+ return output;
337
+ }
338
+
339
+ /*
340
+ * Grows the slot list before any stream bytes are produced, so a failed
341
+ * allocation leaves the encoder usable and the stream unchanged.
342
+ */
343
+ static void reserve_slot(splice_encoder_t *encoder) {
344
+ if (encoder->slot_count < encoder->slot_capacity) return;
345
+
346
+ size_t capacity = encoder->slot_capacity ? encoder->slot_capacity * 2 : 4;
347
+ REALLOC_N(encoder->slots, slot_position_t, capacity);
348
+ encoder->slot_capacity = capacity;
349
+ }
350
+
351
+ static VALUE encoder_slot(VALUE self, VALUE bytes) {
352
+ splice_encoder_t *encoder;
353
+ TypedData_Get_Struct(self, splice_encoder_t, &encoder_type, encoder);
354
+ ensure_writable(encoder);
355
+ check_string(bytes);
356
+
357
+ size_t total_length = (size_t)RSTRING_LEN(bytes);
358
+ if (total_length <= CTX_LEN) rb_raise(eBrotliSpliceError, "slot must be longer than 2 bytes");
359
+
360
+ size_t body_length = total_length - CTX_LEN;
361
+ if (body_length > MAX_SLOT_BODY_LEN) rb_raise(eBrotliSpliceError, "slot body must not exceed 64 KB");
362
+ if (memcmp(RSTRING_PTR(bytes) + body_length, CTX_BYTES, CTX_LEN) != 0) {
363
+ rb_raise(eBrotliSpliceError, "slot must end with the context suffix \\r\\n");
364
+ }
365
+
366
+ reserve_slot(encoder);
367
+
368
+ encoder_operation_t arguments = {encoder, bytes};
369
+ return run_encoder_operation(encoder, encoder_slot_operation, &arguments);
370
+ }
371
+
372
+ static VALUE encoder_finish_operation(VALUE opaque) {
373
+ encoder_operation_t *arguments = (encoder_operation_t *)opaque;
374
+ splice_encoder_t *encoder = arguments->encoder;
375
+ VALUE bytes = arguments->bytes;
376
+ VALUE output = encoder_feed(
377
+ encoder->encoder,
378
+ BROTLI_OPERATION_FINISH,
379
+ (const uint8_t *)RSTRING_PTR(bytes),
380
+ (size_t)RSTRING_LEN(bytes));
381
+ encoder->uncompressed_bytes += (size_t)RSTRING_LEN(bytes);
382
+ encoder->bytes_written += (size_t)RSTRING_LEN(output);
383
+ encoder_terminalize(encoder, ENCODER_FINISHED);
384
+ return output;
385
+ }
386
+
387
+ static VALUE encoder_finish(int argc, VALUE *argv, VALUE self) {
388
+ VALUE bytes;
389
+ rb_scan_args(argc, argv, "01", &bytes);
390
+ if (NIL_P(bytes)) bytes = binary_string(NULL, 0);
391
+
392
+ splice_encoder_t *encoder;
393
+ TypedData_Get_Struct(self, splice_encoder_t, &encoder_type, encoder);
394
+ ensure_writable(encoder);
395
+ check_string(bytes);
396
+
397
+ encoder_operation_t arguments = {encoder, bytes};
398
+ return run_encoder_operation(encoder, encoder_finish_operation, &arguments);
399
+ }
400
+
401
+ static VALUE encoder_close(VALUE self) {
402
+ splice_encoder_t *encoder;
403
+ TypedData_Get_Struct(self, splice_encoder_t, &encoder_type, encoder);
404
+ if (encoder->lifecycle == ENCODER_ACTIVE || encoder->lifecycle == ENCODER_UNINITIALIZED) {
405
+ encoder_terminalize(encoder, ENCODER_CLOSED);
406
+ }
407
+ return Qnil;
51
408
  }
52
409
 
410
+ static const slot_position_t *last_slot(const splice_encoder_t *encoder) {
411
+ return encoder->slot_count ? &encoder->slots[encoder->slot_count - 1] : NULL;
412
+ }
413
+
414
+ static VALUE encoder_slot_offset(VALUE self) {
415
+ splice_encoder_t *encoder;
416
+ TypedData_Get_Struct(self, splice_encoder_t, &encoder_type, encoder);
417
+ const slot_position_t *slot = last_slot(encoder);
418
+ return slot ? SIZET2NUM(slot->offset) : Qnil;
419
+ }
420
+
421
+ static VALUE encoder_slot_length(VALUE self) {
422
+ splice_encoder_t *encoder;
423
+ TypedData_Get_Struct(self, splice_encoder_t, &encoder_type, encoder);
424
+ const slot_position_t *slot = last_slot(encoder);
425
+ return slot ? SIZET2NUM(slot->length) : Qnil;
426
+ }
427
+
428
+ static VALUE encoder_slots(VALUE self) {
429
+ splice_encoder_t *encoder;
430
+ TypedData_Get_Struct(self, splice_encoder_t, &encoder_type, encoder);
431
+
432
+ VALUE slots = rb_ary_new_capa((long)encoder->slot_count);
433
+ for (size_t index = 0; index < encoder->slot_count; index++) {
434
+ VALUE slot = rb_hash_new();
435
+ rb_hash_aset(slot, ID2SYM(rb_intern("offset")), SIZET2NUM(encoder->slots[index].offset));
436
+ rb_hash_aset(slot, ID2SYM(rb_intern("length")), SIZET2NUM(encoder->slots[index].length));
437
+ rb_ary_push(slots, slot);
438
+ }
439
+ return slots;
440
+ }
441
+
442
+ static VALUE encoder_context_suffix(VALUE self) {
443
+ return binary_string(CTX_BYTES, CTX_LEN);
444
+ }
445
+
446
+ typedef struct {
447
+ VALUE encoder;
448
+ VALUE pieces;
449
+ } encode_operation_t;
450
+
53
451
  /*
54
- * call-seq:
55
- * BrotliSplice.encode(html, secret_offset, secret_length, quality: 11) -> Hash
56
- *
57
- * Brotli-encode +html+ with a spliceable slot at the given position.
58
- *
59
- * The slot's last 2 bytes are reserved as a fixed context suffix ("\r\n")
60
- * and cannot be replaced. The replaceable portion is +secret_length - 2+
61
- * bytes.
62
- *
63
- * Returns:
64
- * { data: String, # the Brotli-encoded stream
65
- * secret_offset: Integer, # byte offset of the replaceable data in the stream
66
- * secret_length: Integer, # replaceable byte count (secret_length - 2)
67
- * context_suffix: String } # the 2 fixed context bytes ("\r\n")
452
+ * Pieces alternate between compressed chunks and slots, starting and ending
453
+ * with a compressed chunk: [chunk, slot, chunk, slot, ..., chunk].
68
454
  */
455
+ static VALUE encode_operation(VALUE opaque) {
456
+ encode_operation_t *arguments = (encode_operation_t *)opaque;
457
+ long last = RARRAY_LEN(arguments->pieces) - 1;
458
+ VALUE data = binary_string(NULL, 0);
459
+ for (long index = 0; index < last; index += 2) {
460
+ rb_str_concat(data, rb_funcall(arguments->encoder, rb_intern("write"), 1, RARRAY_AREF(arguments->pieces, index)));
461
+ rb_str_concat(data, rb_funcall(arguments->encoder, rb_intern("slot"), 1, RARRAY_AREF(arguments->pieces, index + 1)));
462
+ }
463
+ rb_str_concat(data, rb_funcall(arguments->encoder, rb_intern("finish"), 1, RARRAY_AREF(arguments->pieces, last)));
464
+ return data;
465
+ }
466
+
467
+ static VALUE close_encoder_ensure(VALUE encoder_value) {
468
+ return encoder_close(encoder_value);
469
+ }
470
+
471
+ static VALUE new_encoder_with_options(VALUE options) {
472
+ return rb_class_new_instance_kw(NIL_P(options) ? 0 : 1, NIL_P(options) ? NULL : &options, cEncoder, RB_PASS_KEYWORDS);
473
+ }
474
+
475
+ static VALUE encode_pieces(VALUE encoder, VALUE pieces) {
476
+ encode_operation_t arguments = {encoder, pieces};
477
+ return rb_ensure(encode_operation, (VALUE)&arguments, close_encoder_ensure, encoder);
478
+ }
479
+
480
+ /*
481
+ * `bounds` holds validated, ascending, non-overlapping [offset, length] pairs.
482
+ * Each slot copy ends in the context suffix, which `Encoder#slot` requires.
483
+ */
484
+ static VALUE slot_pieces(VALUE html, const size_t *bounds, long count) {
485
+ VALUE pieces = rb_ary_new_capa(count * 2 + 1);
486
+ size_t position = 0;
487
+ for (long index = 0; index < count; index++) {
488
+ size_t offset = bounds[index * 2];
489
+ size_t length = bounds[index * 2 + 1];
490
+ rb_ary_push(pieces, binary_string((const uint8_t *)RSTRING_PTR(html) + position, offset - position));
491
+
492
+ VALUE slot = binary_string((const uint8_t *)RSTRING_PTR(html) + offset, length);
493
+ rb_str_modify(slot);
494
+ memcpy(RSTRING_PTR(slot) + length - CTX_LEN, CTX_BYTES, CTX_LEN);
495
+ rb_ary_push(pieces, slot);
496
+ position = offset + length;
497
+ }
498
+ rb_ary_push(pieces, binary_string((const uint8_t *)RSTRING_PTR(html) + position, (size_t)RSTRING_LEN(html) - position));
499
+ return pieces;
500
+ }
501
+
69
502
  static VALUE rb_brotli_splice_encode(int argc, VALUE *argv, VALUE self) {
70
- VALUE rb_html, rb_sec_off, rb_sec_len, rb_opts;
71
- rb_scan_args(argc, argv, "3:", &rb_html, &rb_sec_off, &rb_sec_len, &rb_opts);
72
-
73
- Check_Type(rb_html, T_STRING);
74
- const uint8_t *html = (const uint8_t *)RSTRING_PTR(rb_html);
75
- size_t html_len = RSTRING_LEN(rb_html);
76
- size_t sec_off = NUM2SIZET(rb_sec_off);
77
- size_t sec_total = NUM2SIZET(rb_sec_len);
78
-
79
- if (sec_total <= CTX_LEN)
80
- rb_raise(eBrotliSpliceError, "secret_length must be > %d", CTX_LEN);
81
- if (sec_off + sec_total > html_len)
503
+ VALUE html, secret_offset_value, secret_length_value, options;
504
+ rb_scan_args(argc, argv, "3:", &html, &secret_offset_value, &secret_length_value, &options);
505
+ Check_Type(html, T_STRING);
506
+
507
+ size_t secret_offset = NUM2SIZET(secret_offset_value);
508
+ size_t secret_length = NUM2SIZET(secret_length_value);
509
+ size_t html_length = (size_t)RSTRING_LEN(html);
510
+ if (secret_length <= CTX_LEN) rb_raise(eBrotliSpliceError, "secret_length must be > %d", CTX_LEN);
511
+ if (secret_offset > html_length || secret_length > html_length - secret_offset) {
82
512
  rb_raise(eBrotliSpliceError, "secret_offset + secret_length exceeds html size");
513
+ }
514
+ if (secret_length - CTX_LEN > MAX_SLOT_BODY_LEN) rb_raise(eBrotliSpliceError, "slot body must not exceed 64 KB");
83
515
 
84
- int quality = 11;
85
- if (!NIL_P(rb_opts)) {
86
- VALUE q = rb_hash_aref(rb_opts, ID2SYM(rb_intern("quality")));
87
- if (!NIL_P(q)) quality = NUM2INT(q);
88
- }
89
-
90
- /* Split into part1 / secret_body / context / part3 */
91
- const uint8_t *part1 = html;
92
- size_t p1_len = sec_off;
93
-
94
- size_t sec_body = sec_total - CTX_LEN;
95
- const uint8_t *secret = html + sec_off; /* secret_body bytes */
96
-
97
- const uint8_t *part3 = html + sec_off + sec_total;
98
- size_t p3_len = html_len - sec_off - sec_total;
99
-
100
- /* Allocate output buffer */
101
- size_t out_cap = html_len + 65536;
102
- uint8_t *out = xmalloc(out_cap);
103
- size_t pos = 0;
104
-
105
- /* ── Chunk 1: compress part1 with FLUSH ──────────────────────── */
106
- BrotliEncoderState *enc1 = BrotliEncoderCreateInstance(NULL, NULL, NULL);
107
- if (!enc1) { xfree(out); rb_raise(eBrotliSpliceError, "encoder creation failed"); }
108
- BrotliEncoderSetParameter(enc1, BROTLI_PARAM_QUALITY, quality);
109
- BrotliEncoderSetParameter(enc1, BROTLI_PARAM_LGWIN, 22);
110
-
111
- size_t c1 = encoder_feed(enc1, BROTLI_OPERATION_FLUSH,
112
- part1, p1_len, out + pos, out_cap - pos);
113
- BrotliEncoderDestroyInstance(enc1);
114
- if (c1 == 0 && p1_len > 0) {
115
- xfree(out);
116
- rb_raise(eBrotliSpliceError, "chunk1 encoding failed");
117
- }
118
- pos += c1;
119
-
120
- /* ── Chunk 2: uncompressed meta-block for secret body ────────── */
121
- size_t sec_data_offset = pos + 3; /* 3-byte header for ≤64KB */
122
- size_t c2 = make_uncompressed_block(out + pos, secret, sec_body);
123
- if (c2 == 0) { xfree(out); rb_raise(eBrotliSpliceError, "chunk2 failed"); }
124
- pos += c2;
125
-
126
- /* ── Chunk 3: context bytes + part3, STREAM_OFFSET ───────────── */
127
- BrotliEncoderState *enc2 = BrotliEncoderCreateInstance(NULL, NULL, NULL);
128
- if (!enc2) { xfree(out); rb_raise(eBrotliSpliceError, "encoder2 creation failed"); }
129
- BrotliEncoderSetParameter(enc2, BROTLI_PARAM_QUALITY, quality);
130
- BrotliEncoderSetParameter(enc2, BROTLI_PARAM_LGWIN, 22);
131
- BrotliEncoderSetParameter(enc2, BROTLI_PARAM_STREAM_OFFSET,
132
- (uint32_t)(p1_len + sec_body));
133
-
134
- size_t c3_in_len = CTX_LEN + p3_len;
135
- uint8_t *c3_in = xmalloc(c3_in_len);
136
- memcpy(c3_in, CTX_BYTES, CTX_LEN);
137
- memcpy(c3_in + CTX_LEN, part3, p3_len);
138
-
139
- size_t c3 = encoder_feed(enc2, BROTLI_OPERATION_FINISH,
140
- c3_in, c3_in_len, out + pos, out_cap - pos);
141
- BrotliEncoderDestroyInstance(enc2);
142
- xfree(c3_in);
143
- if (c3 == 0) { xfree(out); rb_raise(eBrotliSpliceError, "chunk3 encoding failed"); }
144
- pos += c3;
145
-
146
- /* Build result */
147
- VALUE data = rb_str_new((char *)out, pos);
148
- rb_enc_associate(data, rb_ascii8bit_encoding());
149
- xfree(out);
516
+ size_t bounds[2] = {secret_offset, secret_length};
517
+ VALUE pieces = slot_pieces(html, bounds, 1);
518
+ VALUE encoder = new_encoder_with_options(options);
519
+ VALUE data = encode_pieces(encoder, pieces);
150
520
 
151
521
  VALUE result = rb_hash_new();
152
522
  rb_hash_aset(result, ID2SYM(rb_intern("data")), data);
153
- rb_hash_aset(result, ID2SYM(rb_intern("secret_offset")), SIZET2NUM(sec_data_offset));
154
- rb_hash_aset(result, ID2SYM(rb_intern("secret_length")), SIZET2NUM(sec_body));
155
- rb_hash_aset(result, ID2SYM(rb_intern("context_suffix")),
156
- rb_str_new((const char *)CTX_BYTES, CTX_LEN));
523
+ rb_hash_aset(result, ID2SYM(rb_intern("secret_offset")), encoder_slot_offset(encoder));
524
+ rb_hash_aset(result, ID2SYM(rb_intern("secret_length")), encoder_slot_length(encoder));
525
+ rb_hash_aset(result, ID2SYM(rb_intern("context_suffix")), encoder_context_suffix(encoder));
157
526
  return result;
158
527
  }
159
528
 
160
- /*
161
- * call-seq:
162
- * BrotliSplice.replace(compressed_data, new_secret, secret_offset, secret_length) -> String
163
- *
164
- * Replace the secret in a compressed stream. +new_secret+ must be
165
- * exactly +secret_length+ bytes. Returns a new string.
166
- */
167
- static VALUE rb_brotli_splice_replace(VALUE self,
168
- VALUE rb_data, VALUE rb_secret,
169
- VALUE rb_offset, VALUE rb_length) {
170
- Check_Type(rb_data, T_STRING);
171
- Check_Type(rb_secret, T_STRING);
172
- size_t offset = NUM2SIZET(rb_offset);
173
- size_t length = NUM2SIZET(rb_length);
174
-
175
- if ((size_t)RSTRING_LEN(rb_secret) != length)
176
- rb_raise(eBrotliSpliceError,
177
- "secret length %ld != expected %zu",
178
- RSTRING_LEN(rb_secret), length);
179
-
180
- if (offset + length > (size_t)RSTRING_LEN(rb_data))
529
+ static VALUE rb_brotli_splice_encode_slots(int argc, VALUE *argv, VALUE self) {
530
+ VALUE html, slots, options;
531
+ rb_scan_args(argc, argv, "2:", &html, &slots, &options);
532
+ Check_Type(html, T_STRING);
533
+ Check_Type(slots, T_ARRAY);
534
+
535
+ /*
536
+ * Coerce every pair before validating any of them: numeric coercion can run
537
+ * Ruby code that changes `html`.
538
+ */
539
+ long count = RARRAY_LEN(slots);
540
+ VALUE bounds_buffer;
541
+ size_t *bounds = ALLOCV_N(size_t, bounds_buffer, (size_t)count * 2);
542
+ for (long index = 0; index < count; index++) {
543
+ VALUE slot = rb_ary_entry(slots, index);
544
+ Check_Type(slot, T_ARRAY);
545
+ if (RARRAY_LEN(slot) != 2) rb_raise(rb_eArgError, "each slot must be an [offset, length] pair");
546
+ bounds[index * 2] = NUM2SIZET(rb_ary_entry(slot, 0));
547
+ bounds[index * 2 + 1] = NUM2SIZET(rb_ary_entry(slot, 1));
548
+ }
549
+
550
+ size_t html_length = (size_t)RSTRING_LEN(html);
551
+ size_t previous_end = 0;
552
+ for (long index = 0; index < count; index++) {
553
+ size_t offset = bounds[index * 2];
554
+ size_t length = bounds[index * 2 + 1];
555
+ if (length <= CTX_LEN) rb_raise(eBrotliSpliceError, "slot length must be > %d", CTX_LEN);
556
+ if (length - CTX_LEN > MAX_SLOT_BODY_LEN) rb_raise(eBrotliSpliceError, "slot body must not exceed 64 KB");
557
+ if (offset > html_length || length > html_length - offset) {
558
+ rb_raise(eBrotliSpliceError, "slot offset + length exceeds html size");
559
+ }
560
+ if (offset < previous_end) rb_raise(eBrotliSpliceError, "slots must be in ascending order and must not overlap");
561
+ previous_end = offset + length;
562
+ }
563
+
564
+ VALUE pieces = slot_pieces(html, bounds, count);
565
+ ALLOCV_END(bounds_buffer);
566
+ VALUE encoder = new_encoder_with_options(options);
567
+ VALUE data = encode_pieces(encoder, pieces);
568
+
569
+ VALUE result = rb_hash_new();
570
+ rb_hash_aset(result, ID2SYM(rb_intern("data")), data);
571
+ rb_hash_aset(result, ID2SYM(rb_intern("slots")), encoder_slots(encoder));
572
+ rb_hash_aset(result, ID2SYM(rb_intern("context_suffix")), encoder_context_suffix(encoder));
573
+ return result;
574
+ }
575
+
576
+ static VALUE rb_brotli_splice_replace(VALUE self, VALUE data, VALUE secret,
577
+ VALUE offset_value, VALUE length_value) {
578
+ Check_Type(data, T_STRING);
579
+ Check_Type(secret, T_STRING);
580
+ size_t offset = NUM2SIZET(offset_value);
581
+ size_t length = NUM2SIZET(length_value);
582
+
583
+ if ((size_t)RSTRING_LEN(secret) != length) {
584
+ rb_raise(eBrotliSpliceError, "secret length %ld != expected %zu", RSTRING_LEN(secret), length);
585
+ }
586
+ if (offset > (size_t)RSTRING_LEN(data) || length > (size_t)RSTRING_LEN(data) - offset) {
181
587
  rb_raise(eBrotliSpliceError, "offset+length exceeds data size");
588
+ }
182
589
 
183
- VALUE result = rb_str_dup(rb_data);
590
+ VALUE result = rb_str_dup(data);
184
591
  rb_str_modify(result);
185
- memcpy(RSTRING_PTR(result) + offset, RSTRING_PTR(rb_secret), length);
592
+ memcpy(RSTRING_PTR(result) + offset, RSTRING_PTR(secret), length);
593
+ return result;
594
+ }
595
+
596
+ static VALUE rb_brotli_splice_replace_slots(VALUE self, VALUE data, VALUE replacements) {
597
+ Check_Type(data, T_STRING);
598
+ Check_Type(replacements, T_ARRAY);
599
+
600
+ /*
601
+ * Coerce every entry before validating any of them: numeric coercion can run
602
+ * Ruby code that changes `data` or a secret.
603
+ */
604
+ long count = RARRAY_LEN(replacements);
605
+ VALUE secrets = rb_ary_new_capa(count);
606
+ VALUE bounds_buffer;
607
+ size_t *bounds = ALLOCV_N(size_t, bounds_buffer, (size_t)count * 2);
608
+ for (long index = 0; index < count; index++) {
609
+ VALUE replacement = rb_ary_entry(replacements, index);
610
+ Check_Type(replacement, T_ARRAY);
611
+ if (RARRAY_LEN(replacement) != 3) {
612
+ rb_raise(rb_eArgError, "each replacement must be a [secret, offset, length] triple");
613
+ }
614
+ VALUE secret = rb_ary_entry(replacement, 0);
615
+ Check_Type(secret, T_STRING);
616
+ rb_ary_push(secrets, secret);
617
+ bounds[index * 2] = NUM2SIZET(rb_ary_entry(replacement, 1));
618
+ bounds[index * 2 + 1] = NUM2SIZET(rb_ary_entry(replacement, 2));
619
+ }
620
+
621
+ size_t data_length = (size_t)RSTRING_LEN(data);
622
+ size_t previous_end = 0;
623
+ for (long index = 0; index < count; index++) {
624
+ VALUE secret = RARRAY_AREF(secrets, index);
625
+ size_t offset = bounds[index * 2];
626
+ size_t length = bounds[index * 2 + 1];
627
+ if ((size_t)RSTRING_LEN(secret) != length) {
628
+ rb_raise(eBrotliSpliceError, "secret length %ld != expected %zu", RSTRING_LEN(secret), length);
629
+ }
630
+ if (offset > data_length || length > data_length - offset) {
631
+ rb_raise(eBrotliSpliceError, "offset+length exceeds data size");
632
+ }
633
+ if (offset < previous_end) {
634
+ rb_raise(eBrotliSpliceError, "replacements must be in ascending order and must not overlap");
635
+ }
636
+ previous_end = offset + length;
637
+ }
638
+
639
+ VALUE result = rb_str_dup(data);
640
+ rb_str_modify(result);
641
+ for (long index = 0; index < count; index++) {
642
+ VALUE secret = RARRAY_AREF(secrets, index);
643
+ memcpy(RSTRING_PTR(result) + bounds[index * 2], RSTRING_PTR(secret), bounds[index * 2 + 1]);
644
+ }
645
+ ALLOCV_END(bounds_buffer);
646
+ RB_GC_GUARD(secrets);
186
647
  return result;
187
648
  }
188
649
 
189
650
  void Init_brotli_splice(void) {
190
651
  mBrotliSplice = rb_define_module("BrotliSplice");
191
652
  eBrotliSpliceError = rb_define_class_under(mBrotliSplice, "Error", rb_eRuntimeError);
653
+ cEncoder = rb_define_class_under(mBrotliSplice, "Encoder", rb_cObject);
654
+
655
+ rb_define_alloc_func(cEncoder, encoder_alloc);
656
+ rb_define_method(cEncoder, "initialize", encoder_initialize, -1);
657
+ rb_define_method(cEncoder, "initialize_copy", encoder_initialize_copy, 1);
658
+ rb_define_method(cEncoder, "write", encoder_write, 1);
659
+ rb_define_method(cEncoder, "slot", encoder_slot, 1);
660
+ rb_define_method(cEncoder, "finish", encoder_finish, -1);
661
+ rb_define_method(cEncoder, "close", encoder_close, 0);
662
+ rb_define_method(cEncoder, "slot_offset", encoder_slot_offset, 0);
663
+ rb_define_method(cEncoder, "slot_length", encoder_slot_length, 0);
664
+ rb_define_method(cEncoder, "slots", encoder_slots, 0);
665
+ rb_define_method(cEncoder, "context_suffix", encoder_context_suffix, 0);
192
666
 
193
- rb_define_singleton_method(mBrotliSplice, "encode",
194
- rb_brotli_splice_encode, -1);
195
- rb_define_singleton_method(mBrotliSplice, "replace",
196
- rb_brotli_splice_replace, 4);
667
+ rb_define_singleton_method(mBrotliSplice, "encode", rb_brotli_splice_encode, -1);
668
+ rb_define_singleton_method(mBrotliSplice, "encode_slots", rb_brotli_splice_encode_slots, -1);
669
+ rb_define_singleton_method(mBrotliSplice, "replace", rb_brotli_splice_replace, 4);
670
+ rb_define_singleton_method(mBrotliSplice, "replace_slots", rb_brotli_splice_replace_slots, 2);
197
671
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BrotliSplice
4
- VERSION = "0.1.1"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,18 +1,18 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brotli_splice
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-25 00:00:00.000000000 Z
11
+ date: 2026-10-27 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  BrotliSplice is a Ruby C extension that creates Brotli-compressed streams
15
- containing a fixed-length uncompressed slot. The slot can be overwritten
15
+ containing fixed-length uncompressed slots. Each slot can be overwritten
16
16
  with a simple byte copy, making it useful for injecting fixed-size secrets
17
17
  or tokens into pre-compressed HTML responses.
18
18
  email: