camada 0.2.0 → 0.2.2

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: 0d3fbef4ecb9e7381ff18ff7f00298f155db47809898b7a2f62ee2d0a3d8d90f
4
- data.tar.gz: 25e2f5efe68f77b02d8903d04b59752ea25587c1ac9834c930ea28d24c14eb98
3
+ metadata.gz: b73c94df7715601ab82c6a470ccd976e6bb934f6dcf6c8c54c5bfc3fe00b97df
4
+ data.tar.gz: 94bd0d19b9259b6e03ae0e6df41ca717afbd9aa915d7b1f273c99b89b3dab425
5
5
  SHA512:
6
- metadata.gz: d2f3c3bb076396b25ced5e13ad2371b53709cbb8c73f7a48fb6553ebdb4e576736b88ca22731fc18c2abbd45cddc65b43861eb99f5073f8bb94af7f92b23b6e3
7
- data.tar.gz: 61947d1b869a9b5ea49fdaca8e8c8e62c66d9677c52889756a369fae54356e51927bc04767fa9b0c250a3bf128bb24d07971ddef7aecb8a5c6936ac3d8a40259
6
+ metadata.gz: 6b6a3f8d9b8185f0979dccfb92498bc6fac1b62fdb1221f0130f69ca606edf5783ef25df6414e11997a13bb1a1dc5b18b874b5e3b570bef8e980230626c55fa6
7
+ data.tar.gz: 0e879615dbc999eed321ca8bc7821498622ee91a0be08345c846508a9cbd74aea4eab42f55eb7468fbc27482d593c190fa8e94b66c19e7482731dc3cf5159100
data/README.md CHANGED
@@ -170,9 +170,7 @@ valid `_cch`, then `nil`. A non-page (non-HTML) request also gets `nil` and is s
170
170
  `sdk-ruby` is an in-app tap: status, latency, session, the beacon's browser signals and your
171
171
  outcomes. Puma hands the env to Rack as binary strings; the middleware reads them as UTF-8 with
172
172
  invalid bytes replaced (U+FFFD) before matching and shipping, so a stray high byte in a header
173
- neither escapes a rule nor costs the batch it rides in. The Rack env carries no wire header order (`hord` is the env's order), so the analyst
174
- reads no HEADER_ORDER signal from this tap, and it never scores the absence of header order, ASN,
175
- country or a TLS fingerprint against a request; ASN and country it resolves itself. Enforcement
173
+ neither escapes a rule nor costs the batch it rides in. The Rack env carries no wire header order (`hord` is the env's order), so this tap ships no header-order signal; camada resolves ASN and country itself. Enforcement
176
174
  at this position covers ip, path, user-agent and header conditions — ASN, country and TLS entries
177
175
  fail open in-app. `matches` patterns are JS regexes read by Ruby's Onigmo: named groups are
178
176
  native, `[^]` and `\cX` are translated, `^`/`$` become `\A`/`\z` (Ruby's are line anchors,
@@ -4,8 +4,8 @@ require "json"
4
4
 
5
5
  module Camada
6
6
  # Tap identifier this SDK claims on the wire. The server validates against its own enum and
7
- # derives the capability mask itself (edge-analyst src/capabilities.js): an SDK can never grant
8
- # itself capability bits, only name its position — and an unknown name is silently read as a
7
+ # decides itself what each tap can see: an SDK can never grant
8
+ # itself more than that, only name its position — and an unknown name is silently read as a
9
9
  # proxy, so this literal is load-bearing.
10
10
  TAP = "sdk-ruby"
11
11
 
@@ -149,9 +149,10 @@ module Camada
149
149
 
150
150
  # A pattern this runtime rejects never matches, and never throws (fail open). Patterns are
151
151
  # authored as JS regexes (the analyst validates them with `new RegExp`), so the JS spellings
152
- # Onigmo reads differently are translated first — see js_to_onigmo; \d \w \b are ASCII in
153
- # Ruby by default, as JS reads them. An astral char is one code point here and two UTF-16 units
154
- # in JS, so `.`, `[^a]` and `\S` count it once (an accepted gap, camada-all-vuvo).
152
+ # Onigmo reads differently are translated first — see js_to_onigmo; \d \w are ASCII in
153
+ # Ruby by default, as JS reads them, and \b \B are emitted as (?a:\b) for the same reason.
154
+ # An astral char is one code point here and two UTF-16 units in JS,
155
+ # so `.`, `[^a]` and `\S` count it once (an accepted gap, camada-all-vuvo).
155
156
  # A quantifier right after a raw astral char repeats the whole char here but only its low unit in JS,
156
157
  # so js_to_onigmo answers as V8 does: the char when the count allows 1, else `(?!)` (camada-all-5lra, see astral_count).
157
158
  # Where a lookbehind ends up holding `(?!)`, Onigmo cannot compile it, so compile_regex returns nil and the rule never matches.
@@ -163,7 +164,8 @@ module Camada
163
164
  # "ff", and an ASCII-only pattern does the same once Ruby recompiles it for a non-ASCII value. Extra matches only, so a rule hits a
164
165
  # value JS would not and never misses one; splitting every literal run would slow string search (an accepted gap, camada-all-wdii).
165
166
  # Inside a lookbehind js_to_onigmo splits adjacent literals (camada-all-ccjo) so it stays fixed-length, which stops ss folding to ß,
166
- # but a lone ß or ff there still matches "SS" / "FF".
167
+ # and reads a multi-fold char (ß, ff, İ) case-sensitively, as JS matches it: Onigmo there reads it as its fold alone (ß as "ss",
168
+ # never "ß"; Ruby 3.3.13 and 3.4.12 do the same for ff and İ, camada-all-ozix).
167
169
  # A backreference JS and Onigmo read differently is rewritten or, when it cannot be, fails open (scan_groups).
168
170
  # A `(?` or `(*` group JS lacks (`(?i)`, `(?>`, `(*FAIL)`, ...) fails open too: JS rejects it or V8 mis-evaluates it (camada-all-lsl8).
169
171
  # Only a UTF-8 or ASCII pattern is walked: snapshot patterns arrive as UTF-8 JSON, and the binary walk
@@ -366,9 +368,12 @@ module Camada
366
368
  # Under IGNORECASE Onigmo folds several chars at once (`ss` ~ ß, and a positive class holding
367
369
  # `\S` gains such folds), which makes a lookbehind variable-length and uncompilable; JS folds one
368
370
  # char at a time. So inside a lookbehind adjacent literal chars are split with `(?:)` (`ab` -> `a(?:)b`; a
369
- # lone literal stays a plain string node, which Onigmo can still expand for a multi-char fold) and a positive
371
+ # lone literal stays a plain string node) and a positive
370
372
  # class with `\S` (or `\W`/`\D`) becomes `(?:[^WS]|[rest])`, and a closed one-char class counts as a literal for
371
- # that split (camada-all-ccjo, camada-all-fe1r).
373
+ # that split (camada-all-ccjo, camada-all-fe1r). A char whose fold is several chars (ß, ff, İ; raw, `\u00df` or `\ß`)
374
+ # becomes `(?-i:ß)` there, pulled out of a positive class like `\S` unless a `-` is beside it (camada-all-ozix):
375
+ # JS /i matches it only to itself, and Onigmo would read it as its fold alone (version-dependent).
376
+ # One of the 63 iota-subscript chars in a positive class is pulled out the same way in any context (camada-all-rmje).
372
377
  # In a class `\c` is a control char only before a letter, digit or `_` (ClassControlLetter); before
373
378
  # anything else (`[\c*]`) it is a literal `\` then `c` (camada-all-mqcb).
374
379
  # A `{m,n}` bound above Onigmo's 100000 is clamped: m to 100000, and an n above it drops (`{m,}`,
@@ -395,11 +400,26 @@ module Camada
395
400
  bars = [-1] # per open group, the root first: the index of the last `|` in it or an ancestor
396
401
  lbs = [false] # per open group, the root first: inside a lookbehind (camada-all-ccjo)
397
402
  lit_end = -1 # the pattern index right after the last literal emitted inside a lookbehind
398
- class_start = nil # the out index of an open positive class's `[` inside a lookbehind
399
- class_alts = [] # the `\S`/`\W`/`\D` that class held
403
+ class_start = nil # the out index of an open positive class's `[`
404
+ class_alts = [] # the `\S`/`\W`/`\D` and multi-fold chars that class held
400
405
  last_named = {} # name -> the index of its last opener
401
406
  while i < n
402
407
  ch = pattern[i]
408
+ w = multi_fold(pattern, i)
409
+ iota = w && IOTA_SUBSCRIPT.match?(w[2])
410
+ if !in_class && !lbs.last && iota
411
+ out << "(?-i:#{w[0]})" # outside a lookbehind (the branch below) and a class; JS /i matches it only to itself (camada-all-zn38)
412
+ i += w[1]
413
+ next
414
+ end
415
+ # in a lookbehind (positive class too, unless a range end) a multi-fold char becomes `(?-i:c)` (camada-all-ozix)
416
+ # an iota-subscript char in a positive class is pulled out the same way, in a lookbehind or not (camada-all-rmje)
417
+ if (lbs.last || (in_class && iota)) && (!in_class || class_start) && w &&
418
+ !(in_class && (pattern[i - 1] == "-" || pattern[i + w[1]] == "-"))
419
+ in_class ? class_alts << "(?-i:#{w[0]})" : out << "(?-i:#{w[0]})"
420
+ i += w[1]
421
+ next
422
+ end
403
423
  if ch == "\\" && i + 1 < n
404
424
  if !in_class && (r = plan[i])
405
425
  if r[1] == :empty
@@ -413,7 +433,7 @@ module Camada
413
433
  next
414
434
  end
415
435
  l = pattern[i + 1]
416
- if in_class && class_start && (l == "S" || l == "W" || l == "D")
436
+ if in_class && class_start && lbs.last && (l == "S" || l == "W" || l == "D")
417
437
  class_alts << (l == "S" ? JS_SPACE["S"] : "\\#{l}") # spliced in at the `]`: a positive class holding one gains multi-char folds
418
438
  pull_end = i + 2
419
439
  i += 2
@@ -425,6 +445,11 @@ module Camada
425
445
  i += 2
426
446
  next
427
447
  end
448
+ if !in_class && (l == "b" || l == "B")
449
+ out << "(?a:\\#{l})" # Onigmo's \b reads é as a word char, JS (no u flag) as ASCII only
450
+ i += 2
451
+ next
452
+ end
428
453
  if l == "c" && pattern[i + 2]&.match?(in_class ? /[A-Za-z0-9_]/ : /[A-Za-z]/) # JS Annex B: `[\c1]` too
429
454
  out << Regexp.escape((pattern[i + 2].ord & 0x1F).chr)
430
455
  i += 3
@@ -447,12 +472,13 @@ module Camada
447
472
  i += 1
448
473
  next
449
474
  end
450
- kept = !l.match?(/[A-Za-z]/) || pattern.match?(JS_ESCAPE, i + 1) || (l == "B" && !in_class) ||
475
+ kept = !l.match?(/[A-Za-z]/) || pattern.match?(JS_ESCAPE, i + 1) ||
451
476
  (l == "k" && named)
452
477
  l = "\\\\c" if l == "c" # JS reads `\c` before a non-letter as text
453
478
  if kept
454
479
  e = (pattern.match(/\G\\\d+/n, i) || pattern.match(JS_ATOM, i))[0] # the whole escape: a lookbehind must not split `\x41`
455
- out << e
480
+ # JS reads \xHH above 0x7F as that code point, Onigmo as a raw byte (camada-all-w2w4)
481
+ out << (e.match?(/\A\\x[89a-fA-F]\h\z/) ? "\\u00#{e[2..]}" : e)
456
482
  i += e.length
457
483
  next
458
484
  end
@@ -477,12 +503,12 @@ module Camada
477
503
  else
478
504
  out << "]"
479
505
  end
480
- lit_end = i + 1 if class_start # Onigmo merges a one-char class into the literals beside it
506
+ lit_end = i + 1 if class_start && lbs.last # Onigmo merges a one-char class into the literals beside it
481
507
  class_start = nil
482
508
  i += 1
483
509
  next
484
510
  end
485
- if ch == "-" && (pull_end == i || solo_end == i || pattern.match?(class_start ? /\G-\\[sSWD]/ : /\G-\\[sS]/, i))
511
+ if ch == "-" && (pull_end == i || solo_end == i || pattern.match?(class_start && lbs.last ? /\G-\\[sSWD]/ : /\G-\\[sS]/, i))
486
512
  if pull_end == i # JS reads the atom after that dash as standalone: a dash right after it is text too
487
513
  m = pattern.match(JS_ATOM, i + 1) # nil when the pattern ends on the dash
488
514
  solo_end = m ? i + 1 + m[0].length : pattern.length
@@ -539,10 +565,8 @@ module Camada
539
565
  i = j + 1
540
566
  next
541
567
  end
542
- if !neg && lbs.last
543
- out << "(?:)" if lit_end == i
544
- class_start = out.length
545
- end
568
+ out << "(?:)" if !neg && lbs.last && lit_end == i
569
+ class_start = out.length unless neg
546
570
  out << pattern[i...j]
547
571
  i = j
548
572
  in_class = true
@@ -614,6 +638,31 @@ module Camada
614
638
  str.encoding == Encoding::BINARY ? str : str.b
615
639
  end
616
640
 
641
+ # The 63 Greek chars whose fold ends in an iota (ᾀ -> ἀι): Onigmo never matches one before a lookaround under /i.
642
+ IOTA_SUBSCRIPT = /\A[\u1F80-\u1FAF\u1FB2-\u1FB4\u1FB7\u1FBC\u1FC2-\u1FC4\u1FC7\u1FCC\u1FF2-\u1FF4\u1FF7\u1FFC]/
643
+
644
+ # [its text, its length, the char] when the char at i (raw UTF-8, `\uXXXX` or an identity escape `\ß`) has a case fold of
645
+ # several chars (ß, İ, ff: 104 of them), else nil. JS /i without `u` matches each of them only to itself.
646
+ def self.multi_fold(pattern, i)
647
+ if pattern[i] == "\\"
648
+ return (w = multi_fold(pattern, i + 1)) && [w[0], w[1] + 1, w[2]] if pattern.getbyte(i + 1).to_i >= 0x80
649
+
650
+ m = pattern.match(/\G\\(?:u(\h{4})|x([89a-fA-F]\h))/n, i) # `\xHH` above 0x7F is the code point (camada-all-w2w4)
651
+ return nil unless m && !(cp = (m[1] || m[2]).hex).between?(0xD800, 0xDFFF)
652
+
653
+ c = cp.chr(Encoding::UTF_8)
654
+ len = m[0].length
655
+ return [format("\\u%04x", cp), len] if c.downcase(:fold).length > 1 && m[2]
656
+ else
657
+ b = pattern.getbyte(i)
658
+ return nil unless b.between?(0xC2, 0xEF) # a 2- or 3-byte lead: every such char is in the BMP
659
+
660
+ len = b < 0xE0 ? 2 : 3
661
+ c = pattern.byteslice(i, len).force_encoding(Encoding::UTF_8)
662
+ end
663
+ [pattern.byteslice(i, len), len, c] if c.valid_encoding? && c.downcase(:fold).length > 1
664
+ end
665
+
617
666
  def self.brace_quantifier(match)
618
667
  lo, hi = canonical_bounds(match[1], match[3])
619
668
  return match[0] unless lo # m > n is a JS SyntaxError: leave it for Onigmo to reject
@@ -6,6 +6,6 @@ module Camada
6
6
  # camada-web, camada-mkt) parse this file with /^\s*VERSION = "([^"]+)"/m the way they parse
7
7
  # a package.json. Plain X.Y.Z only, and @camada/ruby must stay in the analyst's SDK_NAMES:
8
8
  # its sdkId drops anything else.
9
- VERSION = "0.2.0"
9
+ VERSION = "0.2.2"
10
10
  SDK_ID = "@camada/ruby/#{VERSION}".freeze
11
11
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: camada
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - camada
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-26 00:00:00.000000000 Z
11
+ date: 2026-10-28 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: 'camada SDK for Ruby: inline enforcement of your snapshot (ordered custom
14
14
  rules, then allow, block, challenge), first-party beacon and proof-of-work challenge,