brotli_splice 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c2ff7c74f1280217916777270114a52fefb8a6c7c67899db9a6f2017d978e2c
4
- data.tar.gz: 8a4c7335d85ef0364713704e2ee18924596742401f15d63359dba3182a54c5f5
3
+ metadata.gz: ce9fb6c98f2b365b24bad75eb7f1ae7bfef2f86c025a87f27e04d5bf5f8732ee
4
+ data.tar.gz: 5b98a2d69c15302930662ea35ce53b1a987a180e388473083d1c2f89dc59f532
5
5
  SHA512:
6
- metadata.gz: 34fdb4f6643d3924e66214fad37b678806ed3d93333f2e340ec324b5403d1b87b5260909659ab8365e8faaaf4ac5274c28c10a62ea48245a10a768a591d1356e
7
- data.tar.gz: ba10b2081093749fac4f52d5c58b4a513181b04a1a99e7ce67d9c50a29b1669b4ed39030dafcb8581caa29a3fff77565a4418e16df26bb86512840b6902c24f9
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
 
@@ -111,9 +116,9 @@ compressed << encoder.slot("live-token\r\n".b)
111
116
  compressed << encoder.finish("</head><body>...</body></html>".b)
112
117
  ```
113
118
 
114
- `write` flushes and byte-aligns each chunk. `slot` may be called once. 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.
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.
115
120
 
116
- After `slot`, `slot_offset` and `slot_length` identify the raw replaceable bytes in the combined compressed output. `context_suffix` returns `"\r\n"`.
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"`.
117
122
 
118
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`.
119
124
 
@@ -137,6 +142,28 @@ Compress `html` with a spliceable slot at the given byte position.
137
142
  }
138
143
  ```
139
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
+
140
167
  ### `BrotliSplice.replace(compressed_data, new_secret, secret_offset, secret_length) → String`
141
168
 
142
169
  Replace the secret in a compressed stream. Returns a new string with the
@@ -148,6 +175,15 @@ swapped bytes. This is a pure `memcpy` — no compression or decompression.
148
175
  - `secret_offset` — from the `encode` result
149
176
  - `secret_length` — from the `encode` result
150
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
+
151
187
  ## Example
152
188
 
153
189
  ```ruby
@@ -185,8 +221,7 @@ longer, or place the suffix where a line break is natural).
185
221
 
186
222
  ## Limitations
187
223
 
188
- - The replaceable slot must be no larger than 65536 bytes, plus the 2-byte context suffix
224
+ - Each replaceable slot must be no larger than 65536 bytes, plus the 2-byte context suffix
189
225
  - The replacement must be **exactly** `secret_length` bytes (no length changes)
190
- - The last 2 bytes of the original secret region become `\r\n` in the output
191
- - Only one spliceable slot per stream (encode with multiple slots would require
192
- 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
@@ -15,6 +15,8 @@ static VALUE cEncoder;
15
15
  static VALUE eBrotliSpliceError;
16
16
 
17
17
  #define CTX_LEN 2
18
+ /* An uncompressed meta-block header stores MLEN - 1 in 16 bits. */
19
+ #define MAX_SLOT_BODY_LEN 65536
18
20
  #define OUTPUT_CHUNK_SIZE 16384
19
21
  static const uint8_t CTX_BYTES[CTX_LEN] = { '\r', '\n' };
20
22
 
@@ -26,16 +28,21 @@ typedef enum {
26
28
  ENCODER_FAILED,
27
29
  } encoder_lifecycle_t;
28
30
 
31
+ typedef struct {
32
+ size_t offset;
33
+ size_t length;
34
+ } slot_position_t;
35
+
29
36
  typedef struct {
30
37
  BrotliEncoderState *encoder;
31
38
  int quality;
32
39
  int lgwin;
33
40
  size_t uncompressed_bytes;
34
41
  size_t bytes_written;
35
- size_t slot_offset;
36
- size_t slot_length;
42
+ slot_position_t *slots;
43
+ size_t slot_count;
44
+ size_t slot_capacity;
37
45
  size_t native_bytes;
38
- int has_slot;
39
46
  encoder_lifecycle_t lifecycle;
40
47
  } splice_encoder_t;
41
48
 
@@ -87,6 +94,7 @@ static void encoder_terminalize(splice_encoder_t *encoder, encoder_lifecycle_t l
87
94
  static void encoder_free(void *pointer) {
88
95
  splice_encoder_t *encoder = pointer;
89
96
  encoder_destroy_native(encoder);
97
+ xfree(encoder->slots);
90
98
  xfree(encoder);
91
99
  }
92
100
 
@@ -94,8 +102,9 @@ static size_t encoder_memsize(const void *pointer) {
94
102
  if (!pointer) return 0;
95
103
 
96
104
  const splice_encoder_t *encoder = pointer;
97
- if (encoder->native_bytes > SIZE_MAX - sizeof(splice_encoder_t)) return SIZE_MAX;
98
- return sizeof(splice_encoder_t) + encoder->native_bytes;
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;
99
108
  }
100
109
 
101
110
  static const rb_data_type_t encoder_type = {
@@ -319,31 +328,43 @@ static VALUE encoder_slot_operation(VALUE opaque) {
319
328
  CTX_LEN);
320
329
  rb_str_concat(output, context_output);
321
330
 
322
- encoder->slot_offset = encoder->bytes_written + flushed_length + sizeof(header);
323
- encoder->slot_length = body_length;
324
- encoder->has_slot = 1;
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++;
325
334
  encoder->bytes_written += flushed_length + sizeof(header) + body_length + (size_t)RSTRING_LEN(context_output);
326
335
  encoder->uncompressed_bytes += body_length + CTX_LEN;
327
336
  return output;
328
337
  }
329
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
+
330
351
  static VALUE encoder_slot(VALUE self, VALUE bytes) {
331
352
  splice_encoder_t *encoder;
332
353
  TypedData_Get_Struct(self, splice_encoder_t, &encoder_type, encoder);
333
354
  ensure_writable(encoder);
334
355
  check_string(bytes);
335
356
 
336
- if (encoder->has_slot) rb_raise(eBrotliSpliceError, "slot may only be written once");
337
-
338
357
  size_t total_length = (size_t)RSTRING_LEN(bytes);
339
358
  if (total_length <= CTX_LEN) rb_raise(eBrotliSpliceError, "slot must be longer than 2 bytes");
340
359
 
341
360
  size_t body_length = total_length - CTX_LEN;
342
- if (body_length > 65536) rb_raise(eBrotliSpliceError, "slot body must not exceed 64 KB");
361
+ if (body_length > MAX_SLOT_BODY_LEN) rb_raise(eBrotliSpliceError, "slot body must not exceed 64 KB");
343
362
  if (memcmp(RSTRING_PTR(bytes) + body_length, CTX_BYTES, CTX_LEN) != 0) {
344
363
  rb_raise(eBrotliSpliceError, "slot must end with the context suffix \\r\\n");
345
364
  }
346
365
 
366
+ reserve_slot(encoder);
367
+
347
368
  encoder_operation_t arguments = {encoder, bytes};
348
369
  return run_encoder_operation(encoder, encoder_slot_operation, &arguments);
349
370
  }
@@ -386,16 +407,36 @@ static VALUE encoder_close(VALUE self) {
386
407
  return Qnil;
387
408
  }
388
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
+
389
414
  static VALUE encoder_slot_offset(VALUE self) {
390
415
  splice_encoder_t *encoder;
391
416
  TypedData_Get_Struct(self, splice_encoder_t, &encoder_type, encoder);
392
- return encoder->has_slot ? SIZET2NUM(encoder->slot_offset) : Qnil;
417
+ const slot_position_t *slot = last_slot(encoder);
418
+ return slot ? SIZET2NUM(slot->offset) : Qnil;
393
419
  }
394
420
 
395
421
  static VALUE encoder_slot_length(VALUE self) {
396
422
  splice_encoder_t *encoder;
397
423
  TypedData_Get_Struct(self, splice_encoder_t, &encoder_type, encoder);
398
- return encoder->has_slot ? SIZET2NUM(encoder->slot_length) : Qnil;
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;
399
440
  }
400
441
 
401
442
  static VALUE encoder_context_suffix(VALUE self) {
@@ -404,29 +445,60 @@ static VALUE encoder_context_suffix(VALUE self) {
404
445
 
405
446
  typedef struct {
406
447
  VALUE encoder;
407
- VALUE prefix;
408
- VALUE slot;
409
- VALUE suffix;
448
+ VALUE pieces;
410
449
  } encode_operation_t;
411
450
 
451
+ /*
452
+ * Pieces alternate between compressed chunks and slots, starting and ending
453
+ * with a compressed chunk: [chunk, slot, chunk, slot, ..., chunk].
454
+ */
412
455
  static VALUE encode_operation(VALUE opaque) {
413
456
  encode_operation_t *arguments = (encode_operation_t *)opaque;
414
- VALUE data = rb_funcall(arguments->encoder, rb_intern("write"), 1, arguments->prefix);
415
- rb_str_concat(data, rb_funcall(arguments->encoder, rb_intern("slot"), 1, arguments->slot));
416
- rb_str_concat(data, rb_funcall(arguments->encoder, rb_intern("finish"), 1, arguments->suffix));
417
-
418
- VALUE result = rb_hash_new();
419
- rb_hash_aset(result, ID2SYM(rb_intern("data")), data);
420
- rb_hash_aset(result, ID2SYM(rb_intern("secret_offset")), rb_funcall(arguments->encoder, rb_intern("slot_offset"), 0));
421
- rb_hash_aset(result, ID2SYM(rb_intern("secret_length")), rb_funcall(arguments->encoder, rb_intern("slot_length"), 0));
422
- rb_hash_aset(result, ID2SYM(rb_intern("context_suffix")), encoder_context_suffix(arguments->encoder));
423
- return result;
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;
424
465
  }
425
466
 
426
467
  static VALUE close_encoder_ensure(VALUE encoder_value) {
427
468
  return encoder_close(encoder_value);
428
469
  }
429
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
+
430
502
  static VALUE rb_brotli_splice_encode(int argc, VALUE *argv, VALUE self) {
431
503
  VALUE html, secret_offset_value, secret_length_value, options;
432
504
  rb_scan_args(argc, argv, "3:", &html, &secret_offset_value, &secret_length_value, &options);
@@ -439,18 +511,66 @@ static VALUE rb_brotli_splice_encode(int argc, VALUE *argv, VALUE self) {
439
511
  if (secret_offset > html_length || secret_length > html_length - secret_offset) {
440
512
  rb_raise(eBrotliSpliceError, "secret_offset + secret_length exceeds html size");
441
513
  }
514
+ if (secret_length - CTX_LEN > MAX_SLOT_BODY_LEN) rb_raise(eBrotliSpliceError, "slot body must not exceed 64 KB");
442
515
 
443
- VALUE prefix = binary_string((const uint8_t *)RSTRING_PTR(html), secret_offset);
444
- VALUE slot = binary_string((const uint8_t *)RSTRING_PTR(html) + secret_offset, secret_length);
445
- VALUE suffix = binary_string(
446
- (const uint8_t *)RSTRING_PTR(html) + secret_offset + secret_length,
447
- html_length - secret_offset - secret_length);
448
- rb_str_modify(slot);
449
- memcpy(RSTRING_PTR(slot) + secret_length - CTX_LEN, CTX_BYTES, CTX_LEN);
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);
450
520
 
451
- VALUE encoder = rb_class_new_instance_kw(NIL_P(options) ? 0 : 1, NIL_P(options) ? NULL : &options, cEncoder, RB_PASS_KEYWORDS);
452
- encode_operation_t arguments = {encoder, prefix, slot, suffix};
453
- return rb_ensure(encode_operation, (VALUE)&arguments, close_encoder_ensure, encoder);
521
+ VALUE result = rb_hash_new();
522
+ rb_hash_aset(result, ID2SYM(rb_intern("data")), data);
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));
526
+ return result;
527
+ }
528
+
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;
454
574
  }
455
575
 
456
576
  static VALUE rb_brotli_splice_replace(VALUE self, VALUE data, VALUE secret,
@@ -473,6 +593,60 @@ static VALUE rb_brotli_splice_replace(VALUE self, VALUE data, VALUE secret,
473
593
  return result;
474
594
  }
475
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);
647
+ return result;
648
+ }
649
+
476
650
  void Init_brotli_splice(void) {
477
651
  mBrotliSplice = rb_define_module("BrotliSplice");
478
652
  eBrotliSpliceError = rb_define_class_under(mBrotliSplice, "Error", rb_eRuntimeError);
@@ -487,8 +661,11 @@ void Init_brotli_splice(void) {
487
661
  rb_define_method(cEncoder, "close", encoder_close, 0);
488
662
  rb_define_method(cEncoder, "slot_offset", encoder_slot_offset, 0);
489
663
  rb_define_method(cEncoder, "slot_length", encoder_slot_length, 0);
664
+ rb_define_method(cEncoder, "slots", encoder_slots, 0);
490
665
  rb_define_method(cEncoder, "context_suffix", encoder_context_suffix, 0);
491
666
 
492
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);
493
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);
494
671
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BrotliSplice
4
- VERSION = "0.2.0"
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.2.0
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-09-11 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: