camada 0.1.4 → 0.1.5

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: 67d03ce5257d9e94c2b52c35d9c612077918e82dbb1ccd81de6399300368773d
4
- data.tar.gz: 867e0652bc76b81a367b679b16e52ffa8e8e1248307d0e435d74197224433980
3
+ metadata.gz: 86d9659607a031e6d4802b62fa614a63c5cf682c8b25ad753e08dad5d5890f04
4
+ data.tar.gz: 3837b8f2e624efdca08154e3dabe053c4e924af155688be40a2980c59d9370c6
5
5
  SHA512:
6
- metadata.gz: 61724d07eb1e27c2709f5f56c5e13e82497a1c21896dc57dc434c09b26a40bdc74f7485151b6d4e692dc3c71fae3f6d1ec03ca003fd57877e2b7ca08dd4ff8da
7
- data.tar.gz: 3236651e617c201a84d7c68d17c267f564f3ec2519fbe9b20359ff354efa2619bffc891eab86af36205aa2ac5e787ddd9382bd61a52bdd6d2a94521328825508
6
+ metadata.gz: 4b5eaee46ab80bf9d7b8af4fb2499461c4a2ad3a3ba604f691d77bef227eceb97d9698a1a37688bbc2e87949059f86569103cb43b83e96331012d22bae9bf0ee
7
+ data.tar.gz: 99709f2521ed91d8be8dd8714396e7184667814581c401031f33ba9d1b55ad5edda46a5d65139f5fdac44cc918974c768f544919e5ecb9f2fceeb6c6cfde8cf3
@@ -191,10 +191,54 @@ module Camada
191
191
 
192
192
  # A regex test that never raises: a value in an encoding the pattern cannot read (a binary
193
193
  # PATH_INFO with high bytes, say) reads as "no match" rather than an exception on the request path.
194
- def self.regex_hit?(rx, value)
195
- rx.match?(value)
194
+ #
195
+ # `on_error` is what a value the engine cannot read answers. The legacy block-side path regexes
196
+ # keep false; a custom `matches` rule passes `deny` (regex-cap-sdk v1: an unevaluable value is a
197
+ # hit for block/challenge/warn and a miss for skip, the same as an over-cap one, camada-all-cwb4).
198
+ def self.regex_hit?(rx, value, on_error = false)
199
+ rx.match?(utf8_view(value))
196
200
  rescue StandardError
197
- false
201
+ on_error
202
+ end
203
+
204
+ # A value tagged ASCII-8BIT (Rack) whose bytes are valid UTF-8 is read as UTF-8, so a non-ASCII
205
+ # pattern neither raises nor flips a deny/skip outcome. Genuinely invalid bytes stay as they are
206
+ # and take the on_error path.
207
+ def self.utf8_view(v)
208
+ return v if v.encoding == Encoding::UTF_8
209
+
210
+ s = v.dup.force_encoding(Encoding::UTF_8)
211
+ s.valid_encoding? ? s : v
212
+ end
213
+
214
+ # regex-cap-sdk v1 S1: the longest value a `matches` regex runs on, in UTF-16 code units.
215
+ MAX_MATCH_INPUT = 2048
216
+
217
+ # Length in UTF-16 code units (what edge's `v.length` counts: an astral code point is 2). A String
218
+ # that is not valid UTF-8 (Rack hands over ASCII-8BIT) counts its bytes.
219
+ def self.utf16_len(v)
220
+ s = v.dup.force_encoding(Encoding::UTF_8)
221
+ return v.bytesize unless s.valid_encoding?
222
+
223
+ s.length + s.each_char.count { |c| c.ord > 0xFFFF }
224
+ end
225
+
226
+ def self.over_cap?(v)
227
+ v.bytesize > MAX_MATCH_INPUT && utf16_len(v) > MAX_MATCH_INPUT
228
+ end
229
+
230
+ # regex-cap-sdk S2: delete [\x00-\x1f\x7f] from a ua/header value before a `matches` regex runs.
231
+ # Bytewise, so it stays safe on invalid UTF-8.
232
+ def self.strip_control(v)
233
+ v.b.delete("\x00-\x1f\x7f").force_encoding(v.encoding)
234
+ end
235
+
236
+ # regex-cap-sdk S3: a raw control character in the raw path form is spelled %xx, as canon_path does.
237
+ def self.encode_control(p)
238
+ b = p.b
239
+ return p unless b.match?(/[\x00-\x1f\x7f]/n)
240
+
241
+ b.gsub(/[\x00-\x1f\x7f]/n) { |c| format("%%%02x", c.ord) }.force_encoding(p.encoding)
198
242
  end
199
243
 
200
244
  # The same envelope for the plain string ops: Puma hands every env value to Rack as
@@ -275,7 +319,7 @@ module Camada
275
319
  p = strip_query(raw)
276
320
  return [p, p, p] if canonical?(p)
277
321
 
278
- [p, canon_path(p, dots: false), canon_path(p)]
322
+ [encode_control(p), canon_path(p, dots: false), canon_path(p)]
279
323
  end
280
324
 
281
325
  def self.path_dir(p) = p.end_with?("/") ? p : "#{p}/"
@@ -308,12 +352,14 @@ module Camada
308
352
  deny ? (pred.call(forms[0]) || pred.call(forms[1]) || pred.call(forms[2])) : (pred.call(forms[1]) && pred.call(forms[2]))
309
353
  end
310
354
 
311
- # One path condition -> a predicate over a single path form.
312
- def self.path_pred(op, values)
355
+ # One path condition -> a predicate over a single path form. A `matches` form over MAX_MATCH_INPUT
356
+ # never runs the regex: it answers `deny` (a hit for block/challenge/warn, a miss for skip), and so
357
+ # does a form the regex engine cannot read.
358
+ def self.path_pred(op, values, deny = true)
313
359
  case op
314
360
  when "matches"
315
361
  rx = compile_regex(values[0], Regexp::IGNORECASE)
316
- ->(p) { !rx.nil? && regex_hit?(rx, p) }
362
+ ->(p) { !rx.nil? && (over_cap?(p) ? deny : regex_hit?(rx, p, deny)) }
317
363
  when "starts_with"
318
364
  v = values[0]
319
365
  key = v.end_with?("/") ? dir_key(v) : canon_path(v)
@@ -345,7 +391,7 @@ module Camada
345
391
  op = c["op"].to_s
346
392
  if f == "path" # every path op reads the canonical forms (see "paths" above)
347
393
  raw = c["v"]
348
- pred = path_pred(op, raw.is_a?(Array) ? raw.map(&:to_s) : [raw.to_s])
394
+ pred = path_pred(op, raw.is_a?(Array) ? raw.map(&:to_s) : [raw.to_s], deny)
349
395
  return ->(r) { path_hit?(r.paths, deny, &pred) }
350
396
  end
351
397
  negate = op == "is_not" || op == "not_in"
@@ -386,9 +432,20 @@ module Camada
386
432
  case op
387
433
  when "matches"
388
434
  rx = compile_regex(values[0])
435
+ strip = f == "ua" || f == "header"
389
436
  lambda do |r|
390
437
  v = read.call(r)
391
- !v.nil? && !rx.nil? && regex_hit?(rx, v)
438
+ next false if v.nil? || rx.nil?
439
+
440
+ # The cap comes before regex_hit?, whose rescue would turn an encoding error into a miss.
441
+ hit = ->(s) { over_cap?(s) ? deny : regex_hit?(rx, s, deny) }
442
+ next hit.call(v) unless strip
443
+
444
+ stripped = strip_control(v) # one scan (S5)
445
+ next hit.call(v) if stripped.bytesize == v.bytesize
446
+
447
+ # S5: control chars never widen a skip and never neuter a deny regex
448
+ deny && (hit.call(v) || hit.call(stripped))
392
449
  end
393
450
  when "contains"
394
451
  needle = values[0]
@@ -5,6 +5,6 @@ module Camada
5
5
  # source: camada.gemspec reads it at build, and the sibling drift guards (camada-backend,
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: the analyst's SDK_RE drops anything else.
8
- VERSION = "0.1.4"
8
+ VERSION = "0.1.5"
9
9
  SDK_ID = "@camada/ruby/#{VERSION}".freeze
10
10
  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.1.4
4
+ version: 0.1.5
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-12 00:00:00.000000000 Z
11
+ date: 2026-10-19 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,