camada 0.1.0 → 0.1.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: 628f9e7c2f9d061a56e7627695b0fccb7e7d3416d284878e9b931b3be4dc82bf
4
- data.tar.gz: 735de6952ab6f22dc6e5c5c72773458d3ec9d165c6db9cb71dbc933a0e8fe401
3
+ metadata.gz: 450a1a841ac03fb9490c0a6c1c3e45d62b0eec927795bb3fc3b2064f89432349
4
+ data.tar.gz: 8cd0ffee6f1686a7ad8150094898f3faaaff7f7b797306b5bb2af936a05ccc84
5
5
  SHA512:
6
- metadata.gz: 4d14f47f17124f90ebca0bcfba5cb314bec5f17d4807d2e2adcd0ba9ac58ba8bd5277983732c07ba06778222b69882ee46c7b2049439f7f58e4c180a7e961e1c
7
- data.tar.gz: 76a85c3d6d4720d79c9f0c1ba223e7dfdc5109edeb16b9659ce876eccb19923e8ff4d3ff1a2c5df94260e52a41d4266c694fbd65ec99dbcf34e9fdfba0f18d30
6
+ metadata.gz: 27a5225cff7c94d36f2d5d1d85e91a4304bc71d7edff69c5706ffb9cba903132e67723e650f991a1445abd96f402ac3b2fa920240bc6184e82441870dc0a94cb
7
+ data.tar.gz: 677d632f45dab7351fd8982c396335a9ab02b8bfc1f6598301b4b1c334d520922d6b11e543353e314b8c5aead17b0b66ae4aefbaac6e5ccec0a9e773aa066f58
data/README.md CHANGED
@@ -8,7 +8,7 @@ a Railtie that mounts it for Rails — the `sentry-ruby` model. Fails open by de
8
8
  outage or bug never 5xxes your app.
9
9
 
10
10
  Not yet on RubyGems — install it from a sibling checkout: `gem "camada", path: "../camada-ruby"`
11
- in your Gemfile (as [`camada-ruby-example`](../camada-ruby-example) does); publishing is one
11
+ in your Gemfile (as [`camada-ruby-example`](https://github.com/camada-app/camada-ruby-example) does); publishing is one
12
12
  decision with the npm packages (SDK-G01). Ruby 3.1 or newer, no runtime dependencies (stdlib
13
13
  only: `net/http`, `openssl`, `digest`, `json`, `securerandom`, `zlib`, `stringio`).
14
14
 
@@ -23,7 +23,7 @@ run App
23
23
  # Rails — nothing to add: the Railtie inserts Camada::Rack at the top of the middleware stack
24
24
  ```
25
25
 
26
- Env (printed by camada onboarding / `npm run seed` in dev):
26
+ Env (the key is printed once when you create a project in the app):
27
27
 
28
28
  ```
29
29
  CAMADA_KEY=<ingest_token>.<snap_token>
@@ -83,11 +83,11 @@ use Camada::Rack, engine
83
83
  5. Serves the beacon: `GET /_cam/b.js` (the `@camada/browser` build, vendored) and `POST /_cam/fp`
84
84
  (≤ 32 KB, relayed onto the event batch as a `sig: 1` row with the ip camada resolved). Both
85
85
  fall through to your app when the tenant switched the beacon off.
86
- 6. Runs your app with `x-rid` and the `_sfp` session cookie on its response, and when the server
87
- closes the response body ships one redacted event: method, host, path, scrubbed query, status,
88
- latency, header names/sizes/order, the auth scheme (never the credential), cookie count (never
89
- values), the matched route pattern when the framework names it (`sinatra.route`, Rails'
90
- `route_uri_pattern`). An exception in your app ships as `st: 500` and propagates unchanged.
86
+ 6. Runs your app with `x-rid` (the rid of the request's event row; never on a 101 handshake) and
87
+ the `_sfp` session cookie on its response, and when the server closes the response body ships
88
+ one redacted event: method, host, path, scrubbed query, status, latency, header
89
+ names/sizes/order, the auth scheme (never the credential), cookie count (never values), the
90
+ matched route pattern when the framework names it (`sinatra.route`, Rails' `route_uri_pattern`). An exception in your app ships as `st: 500` and propagates unchanged.
91
91
 
92
92
  ## Options
93
93
 
@@ -194,6 +194,6 @@ vendored beacon to `camada-browser/dist/auto.global.js` (`npm run build` there f
194
194
  `ruby scripts/sync_beacon.rb` after a beacon release). Both fail by name when the checkout is
195
195
  missing rather than skipping (`CAMADA_FIXTURES_DIR`, `CAMADA_BROWSER_DIST` override the paths).
196
196
 
197
- [`camada-ruby-example`](../camada-ruby-example) is the hand-test bench (Sinatra under Puma on
197
+ [`camada-ruby-example`](https://github.com/camada-app/camada-ruby-example) is the hand-test bench (Sinatra under Puma on
198
198
  :3004), and `node scripts/e2e-sdk-ruby.mjs` in `camada/edge-analyst` drives it against a seeded
199
199
  local analyst over real HTTP, cold first request included.
@@ -4,7 +4,7 @@
4
4
  # dist/auto.global.js) vendored as a String literal: served at /_cam/b.js with no runtime file
5
5
  # read. spec/beacon_js_spec.rb pins it to the sibling build byte for byte.
6
6
  module Camada
7
- BEACON_VERSION = "0.2.0"
8
- BEACON_SHA256 = "f0de88bef775d44b76e363d9cb6b87da7bb908172d686921326b635ff03f012b"
9
- BEACON_JS = "\"use strict\";(()=>{var k=\"0.2.0\";var _=\"@camada/browser\";var b=`${_}/${k}`;function n(t){try{return t()}catch{return null}}function x(t){let e=2166136261;for(let i=0;i<t.length;i++)e^=t.charCodeAt(i),e=Math.imul(e,16777619);return(e>>>0).toString(16)}function M(){let t=document.createElement(\"canvas\");t.width=240,t.height=60;let e=t.getContext(\"2d\");return e?(e.textBaseline=\"alphabetic\",e.fillStyle=\"#f60\",e.fillRect(120,5,60,20),e.fillStyle=\"#069\",e.font=\"15px Arial\",e.fillText(\"Sfp\\u2603 1.0\",2,15),e.fillStyle=\"rgba(102,204,0,0.7)\",e.font=\"17px Times\",e.fillText(\"Sfp\\u2603 1.0\",4,45),x(t.toDataURL())):null}function R(){let t=document.createElement(\"canvas\"),e=t.getContext(\"webgl\")||t.getContext(\"experimental-webgl\");if(!e)return null;let i=e.getExtension(\"WEBGL_debug_renderer_info\");return{v:i?e.getParameter(i.UNMASKED_VENDOR_WEBGL):e.getParameter(e.VENDOR),r:i?e.getParameter(i.UNMASKED_RENDERER_WEBGL):e.getParameter(e.RENDERER)}}function S(){return x([Math.tan(-1e300),Math.sinh(1),Math.expm1(1),Math.cbrt(100),Math.log1p(10),Math.atanh(.5)].join(\"|\"))}var N=/^\\$?cdc_|^__webdriver|^__selenium|^__nightmare|^__playwright|^callPhantom/,L=/^\\$?cdc_|^__webdriver|^__selenium|^__nightmare|^__playwright|^callPhantom|^_phantom/;function j(){let t=document,e=navigator,i=window,o=0;for(let s in t)N.test(s)&&o++;for(let s in i)L.test(s)&&o++;return{wd:e.webdriver===!0?1:0,cdc:o,chrome:typeof i.chrome<\"u\"?1:0,plugins:e.plugins?e.plugins.length:-1,langs:e.languages?e.languages.length:-1,outer:window.outerWidth===0&&window.outerHeight===0?1:0,perm:n(()=>Notification.permission),pdf:typeof e.pdfViewerEnabled==\"boolean\"?e.pdfViewerEnabled?1:0:-1,err:n(()=>{try{null.x}catch(s){let c=s.stack;return c?c.split(`\n`).length:0}})}}function T({endpoint:t,rid:e}){let i=e||null,o=performance.now(),s=0,c=0,m=0,h=0,f=0,u=null;function l(){u===null&&(u=Math.round(performance.now()-o))}addEventListener(\"mousemove\",()=>{s++,l()},{passive:!0}),addEventListener(\"scroll\",()=>{c++,l()},{passive:!0}),addEventListener(\"keydown\",()=>{m++,l()},{passive:!0}),addEventListener(\"touchstart\",()=>{h++,l()},{passive:!0}),addEventListener(\"click\",()=>{f++,l()},{passive:!0});function w(){let r=navigator,a=n(()=>performance.getEntriesByType(\"navigation\")[0]),D=n(()=>{let p=performance.getEntriesByType(\"paint\");return p.length?Math.round(p[p.length-1].startTime):null}),d=n(()=>r.connection)||{};return{sdk:b,rid:i,ts:Date.now(),scr:n(()=>[screen.width,screen.height,screen.availWidth,screen.availHeight,screen.colorDepth]),win:n(()=>[window.innerWidth,window.innerHeight,window.outerWidth,window.outerHeight]),dpr:n(()=>window.devicePixelRatio),tz:n(()=>Intl.DateTimeFormat().resolvedOptions().timeZone),tzo:n(()=>new Date().getTimezoneOffset()),lang:n(()=>r.language),langs:n(()=>r.languages?Array.prototype.slice.call(r.languages,0,5):null),plat:n(()=>r.platform),cores:n(()=>r.hardwareConcurrency),mem:n(()=>r.deviceMemory),touch:n(()=>r.maxTouchPoints),ua:n(()=>r.userAgent),dnt:n(()=>r.doNotTrack),cookies:n(()=>r.cookieEnabled?1:0),conn:n(()=>[d.effectiveType,d.rtt,d.downlink,d.saveData?1:0]),gl:n(R),cv:n(M),mf:n(S),auto:n(j),vis:n(()=>document.visibilityState),focus:n(()=>document.hasFocus()?1:0),timing:a?[Math.round(a.domContentLoadedEventEnd),Math.round(a.loadEventEnd),Math.round(a.responseEnd-a.requestStart)]:null,paint:D,input:{moves:s,scrolls:c,keys:m,touches:h,clicks:f,first:u,at:Math.round(performance.now()-o)}}}function v(r){let a=JSON.stringify(r?Object.assign(w(),{hi:r}):w());navigator.sendBeacon&&navigator.sendBeacon(t,new Blob([a],{type:\"application/json\"}))||fetch(t,{method:\"POST\",body:a,keepalive:!0,headers:{\"content-type\":\"application/json\"}}).catch(()=>{})}let g=navigator.userAgentData,E=g&&g.getHighEntropyValues?g.getHighEntropyValues([\"architecture\",\"bitness\",\"model\",\"platformVersion\",\"fullVersionList\"]).catch(()=>null):Promise.resolve(null);setTimeout(()=>{E.then(v)},2e3),addEventListener(\"pagehide\",()=>{E.then(r=>{v(r)})},{once:!0})}function O(t){let e=new URL(t,\"http://_\"),i=e.pathname.split(\"/\");return i[i.length-1]=\"fp\",{endpoint:i.join(\"/\"),rid:e.searchParams.get(\"r\")}}(function(){try{let t=document.currentScript,e=t&&t.src;if(!e)return;let{endpoint:i,rid:o}=O(e);T({endpoint:i,rid:o})}catch{}})();})();\n"
7
+ BEACON_VERSION = "0.3.0"
8
+ BEACON_SHA256 = "0abf522f9d0004ae798ba22fec74e935f545f787847574c4800662d00d522e95"
9
+ BEACON_JS = "\"use strict\";(()=>{var k=\"0.3.0\";var _=\"@camada/browser\";var b=`${_}/${k}`;function n(t){try{return t()}catch{return null}}function x(t){let e=2166136261;for(let i=0;i<t.length;i++)e^=t.charCodeAt(i),e=Math.imul(e,16777619);return(e>>>0).toString(16)}function M(){let t=document.createElement(\"canvas\");t.width=240,t.height=60;let e=t.getContext(\"2d\");return e?(e.textBaseline=\"alphabetic\",e.fillStyle=\"#f60\",e.fillRect(120,5,60,20),e.fillStyle=\"#069\",e.font=\"15px Arial\",e.fillText(\"Sfp\\u2603 1.0\",2,15),e.fillStyle=\"rgba(102,204,0,0.7)\",e.font=\"17px Times\",e.fillText(\"Sfp\\u2603 1.0\",4,45),x(t.toDataURL())):null}function R(){let t=document.createElement(\"canvas\"),e=t.getContext(\"webgl\")||t.getContext(\"experimental-webgl\");if(!e)return null;let i=e.getExtension(\"WEBGL_debug_renderer_info\");return{v:i?e.getParameter(i.UNMASKED_VENDOR_WEBGL):e.getParameter(e.VENDOR),r:i?e.getParameter(i.UNMASKED_RENDERER_WEBGL):e.getParameter(e.RENDERER)}}function S(){return x([Math.tan(-1e300),Math.sinh(1),Math.expm1(1),Math.cbrt(100),Math.log1p(10),Math.atanh(.5)].join(\"|\"))}var N=/^\\$?cdc_|^__webdriver|^__selenium|^__nightmare|^__playwright|^callPhantom/,L=/^\\$?cdc_|^__webdriver|^__selenium|^__nightmare|^__playwright|^callPhantom|^_phantom/;function j(){let t=document,e=navigator,i=window,o=0;for(let s in t)N.test(s)&&o++;for(let s in i)L.test(s)&&o++;return{wd:e.webdriver===!0?1:0,cdc:o,chrome:typeof i.chrome<\"u\"?1:0,plugins:e.plugins?e.plugins.length:-1,langs:e.languages?e.languages.length:-1,outer:window.outerWidth===0&&window.outerHeight===0?1:0,perm:n(()=>Notification.permission),pdf:typeof e.pdfViewerEnabled==\"boolean\"?e.pdfViewerEnabled?1:0:-1,err:n(()=>{try{null.x}catch(s){let c=s.stack;return c?c.split(`\n`).length:0}})}}function T({endpoint:t,rid:e}){let i=e||null,o=performance.now(),s=0,c=0,m=0,h=0,f=0,u=null;function l(){u===null&&(u=Math.round(performance.now()-o))}addEventListener(\"mousemove\",()=>{s++,l()},{passive:!0}),addEventListener(\"scroll\",()=>{c++,l()},{passive:!0}),addEventListener(\"keydown\",()=>{m++,l()},{passive:!0}),addEventListener(\"touchstart\",()=>{h++,l()},{passive:!0}),addEventListener(\"click\",()=>{f++,l()},{passive:!0});function w(){let r=navigator,a=n(()=>performance.getEntriesByType(\"navigation\")[0]),D=n(()=>{let p=performance.getEntriesByType(\"paint\");return p.length?Math.round(p[p.length-1].startTime):null}),d=n(()=>r.connection)||{};return{sdk:b,rid:i,ts:Date.now(),scr:n(()=>[screen.width,screen.height,screen.availWidth,screen.availHeight,screen.colorDepth]),win:n(()=>[window.innerWidth,window.innerHeight,window.outerWidth,window.outerHeight]),dpr:n(()=>window.devicePixelRatio),tz:n(()=>Intl.DateTimeFormat().resolvedOptions().timeZone),tzo:n(()=>new Date().getTimezoneOffset()),lang:n(()=>r.language),langs:n(()=>r.languages?Array.prototype.slice.call(r.languages,0,5):null),plat:n(()=>r.platform),cores:n(()=>r.hardwareConcurrency),mem:n(()=>r.deviceMemory),touch:n(()=>r.maxTouchPoints),ua:n(()=>r.userAgent),dnt:n(()=>r.doNotTrack),cookies:n(()=>r.cookieEnabled?1:0),conn:n(()=>[d.effectiveType,d.rtt,d.downlink,d.saveData?1:0]),gl:n(R),cv:n(M),mf:n(S),auto:n(j),vis:n(()=>document.visibilityState),focus:n(()=>document.hasFocus()?1:0),timing:a?[Math.round(a.domContentLoadedEventEnd),Math.round(a.loadEventEnd),Math.round(a.responseEnd-a.requestStart)]:null,paint:D,input:{moves:s,scrolls:c,keys:m,touches:h,clicks:f,first:u,at:Math.round(performance.now()-o)}}}function v(r){let a=JSON.stringify(r?Object.assign(w(),{hi:r}):w());navigator.sendBeacon&&navigator.sendBeacon(t,new Blob([a],{type:\"application/json\"}))||fetch(t,{method:\"POST\",body:a,keepalive:!0,headers:{\"content-type\":\"application/json\"}}).catch(()=>{})}let g=navigator.userAgentData,E=g&&g.getHighEntropyValues?g.getHighEntropyValues([\"architecture\",\"bitness\",\"model\",\"platformVersion\",\"fullVersionList\"]).catch(()=>null):Promise.resolve(null);setTimeout(()=>{E.then(v)},2e3),addEventListener(\"pagehide\",()=>{E.then(r=>{v(r)})},{once:!0})}function O(t){let e=new URL(t,\"http://_\"),i=e.pathname.split(\"/\");return i[i.length-1]=\"fp\",{endpoint:i.join(\"/\"),rid:e.searchParams.get(\"r\")}}(function(){try{let t=document.currentScript,e=t&&t.src;if(!e)return;let{endpoint:i,rid:o}=O(e);T({endpoint:i,rid:o})}catch{}})();})();\n"
10
10
  end
data/lib/camada/engine.rb CHANGED
@@ -204,6 +204,7 @@ module Camada
204
204
  return INERT if disabled? || @snap.nil?
205
205
 
206
206
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
207
+ ts0 = now_ms # ts is the request start, the moment dur counts from
207
208
  @snap.ensure_fresh
208
209
  ip = client_ip(req)
209
210
 
@@ -265,6 +266,7 @@ module Camada
265
266
  next if ctx["challenged"] || excluded || !sampled
266
267
 
267
268
  ev = event(req, rid, sid, new_session, ip)
269
+ ev["ts"] = ts0
268
270
  ev["st"] = status
269
271
  ev["dur"] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).to_i
270
272
  ev["rt"] = req.route if req.route
data/lib/camada/env.rb CHANGED
@@ -21,9 +21,8 @@ module Camada
21
21
  )
22
22
 
23
23
  class Env
24
- # PLACEHOLDER default, the same one @camada/node and camada-python carry — confirm the
25
- # production ingest domain before any RubyGems publish.
26
- DEFAULT_INGEST_URL = "https://in.camada.dev"
24
+ # Production ingest; CAMADA_INGEST_URL overrides it.
25
+ DEFAULT_INGEST_URL = "https://in.camada.app"
27
26
 
28
27
  # nil (SDK stays inert, one log line) rather than raising on bad config. `env` is anything
29
28
  # answering #[] with strings: ENV, or a Hash.
@@ -4,7 +4,7 @@ module Camada
4
4
  # The fail-open envelope: a camada bug must never 5xx the customer. Every public entry point of
5
5
  # the SDK catches, falls back, and reports through log_rate_limited: at most one line a minute.
6
6
  module Guarded
7
- @last_log = 0.0
7
+ @last_log = nil # monotonic seconds of the last line; nil until the first one
8
8
  @logger = nil
9
9
 
10
10
  class << self
@@ -12,8 +12,10 @@ module Camada
12
12
  attr_accessor :logger
13
13
 
14
14
  def log_rate_limited(err)
15
+ # CLOCK_MONOTONIC counts from boot, so a numeric "never" sentinel like 0.0 would
16
+ # swallow the first line on a host younger than a minute (fresh CI runners are).
15
17
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
16
- return if now - @last_log < 60
18
+ return if @last_log && now - @last_log < 60
17
19
 
18
20
  @last_log = now
19
21
  line = "[camada] suppressed error (SDK fails open): #{describe(err)}"
data/lib/camada/rack.rb CHANGED
@@ -165,7 +165,7 @@ module Camada
165
165
  passed.on_finish&.call(500)
166
166
  raise
167
167
  end
168
- headers = stamp(headers, passed)
168
+ headers = stamp(status, headers, passed)
169
169
  finish = passed.on_finish
170
170
  return [status, headers, body] if finish.nil?
171
171
 
@@ -176,13 +176,14 @@ module Camada
176
176
  [status, headers, proxy]
177
177
  end
178
178
 
179
- # x-rid and the session cookie on the app's response. Rack 3 spells header names in lower
179
+ # x-rid and the session cookie on the app's response; no x-rid on a 101 (a partial-hijack
180
+ # websocket handshake), whose rid rides the event alone. Rack 3 spells header names in lower
180
181
  # case and carries several set-cookie values as an Array; Rack 2 joins them with "\n".
181
- def stamp(headers, passed)
182
+ def stamp(status, headers, passed)
182
183
  return headers if passed.rid.nil? && passed.set_cookie.nil?
183
184
 
184
185
  headers = headers.to_h
185
- headers["x-rid"] = passed.rid if passed.rid
186
+ headers["x-rid"] = passed.rid if passed.rid && status.to_i != 101
186
187
  if passed.set_cookie
187
188
  key = headers.keys.find { |k| k.to_s.downcase == "set-cookie" } || "set-cookie"
188
189
  headers[key] = join_cookies(headers[key], passed.set_cookie)
@@ -15,7 +15,7 @@ module Camada
15
15
  # 200 [u32 LE meta-length][meta JSON][BLK container] + etag + x-camada-config
16
16
  # 304 nothing changed; config header repeated (config refreshes every poll for free)
17
17
  # 204 authenticated, no snapshot published -> enforce nothing, fail open
18
- # Semantics ported exactly: single-in-flight load; loaded_at stamped even on 204 (retry per
18
+ # Semantics ported exactly: single-in-flight load; loaded_at stamped last, even on 204 (retry per
19
19
  # poll cadence, not per request); any error keeps the previous snapshot; cold = fail open.
20
20
  # Timers are threads here: timer mode runs one thread per client sleeping on a condition
21
21
  # variable; lazy mode kicks a one-shot thread from ensure_fresh so the request path never
@@ -153,7 +153,17 @@ module Camada
153
153
  res = @transport.call(HttpRequest.new(method: "GET", url: @url, headers: headers, body: nil, timeout_s: @timeout_s))
154
154
  return unless [200, 204, 304].include?(res.status) # 401/5xx/network: keep what we have
155
155
 
156
- @loaded_at = monotonic
156
+ # loaded_at is stamped last (even when the body turns out corrupt): "not cold" is what Puma's
157
+ # request threads read as "rules in place", so it must not be visible before the matcher and
158
+ # config are. It is assigned after both, so a thread that sees it sees them.
159
+ begin
160
+ publish(res)
161
+ ensure
162
+ @loaded_at = monotonic
163
+ end
164
+ end
165
+
166
+ def publish(res)
157
167
  read_config(res.headers["x-camada-config"])
158
168
  return if res.status == 304
159
169
 
@@ -43,23 +43,6 @@ module Camada
43
43
  end
44
44
  end
45
45
 
46
- def self.clean_path(raw)
47
- p = raw.nil? || raw.empty? ? "/" : raw
48
- q = p.index("?")
49
- q.nil? ? p : p[0, q]
50
- end
51
-
52
- # Walks every '/'-terminated ancestor of `path`, the way the block side does.
53
- def self.prefix_hit?(prefixes, path)
54
- i = path.index("/", 1)
55
- until i.nil?
56
- return true if prefixes.include?(path[0, i + 1])
57
-
58
- i = path.index("/", i + 1)
59
- end
60
- false
61
- end
62
-
63
46
  # A rule decided this request (§D3): at most one of allowed / block / challenge / warn is
64
47
  # true, `reason` is 'rule', and `rule` names the id the adapters stamp on the event.
65
48
  def self.rule_result(rule, version)
@@ -89,20 +72,21 @@ module Camada
89
72
  n4 = IpParse.parse_ip4(ip)
90
73
  end
91
74
  end
75
+ forms = Snapshot.path_forms(i.path) # [raw, lit, full]: contracts §D3 "Path matching"
92
76
  unless s.rules.empty?
93
77
  r = RuleRequest.new(n4: n4, ip6: w, asn: i.asn, country: i.country, tlsx: i.tlsx,
94
- path: Snapshot.clean_path(i.path), ua: i.ua, header: i.header)
78
+ paths: forms, ua: i.ua, header: i.header)
95
79
  s.rules.each do |rule| # the order IS the precedence (§A4): first match wins
96
80
  return Snapshot.rule_result(rule, s.version) if rule.conds.all? { |cond| cond.call(r) }
97
81
  end
98
82
  end
99
- reason = side(s.allow, i, n4, w)
83
+ reason = side(s.allow, i, n4, w, forms, false) # an exemption: every canonical spelling must agree
100
84
  return MatchResult.new(allowed: true, reason: reason, version: s.version) if reason
101
85
 
102
- reason = block_side(i, n4, w)
86
+ reason = block_side(i, n4, w, forms)
103
87
  return MatchResult.new(block: true, reason: reason, version: s.version) if reason
104
88
 
105
- reason = side(s.challenge, i, n4, w)
89
+ reason = side(s.challenge, i, n4, w, forms, true)
106
90
  return MatchResult.new(challenge: true, reason: reason, version: s.version) if reason
107
91
 
108
92
  MatchResult.new(version: s.version)
@@ -178,23 +162,16 @@ module Camada
178
162
  false
179
163
  end
180
164
 
181
- def blocked_path?(path)
182
- s = @snap
183
- return true if s.paths_exact.include?(path)
184
- return true if !s.paths_prefix.empty? && Snapshot.prefix_hit?(s.paths_prefix, path)
185
-
186
- s.paths_regex.any? { |rx| Snapshot.regex_hit?(rx, path) }
187
- end
188
-
189
165
  # The block side: v3 sections plus the top-level meta.
190
- def block_side(i, n4, w)
166
+ def block_side(i, n4, w, forms)
191
167
  s = @snap
192
168
  return "ip4" if n4 >= 0 && blocked4?(n4)
193
169
  return "ip6" if !w.nil? && blocked6?(w)
194
170
  return "asn" if !i.asn.nil? && blocked_asn?(i.asn)
195
171
  return "country" if Camada.present(i.country) && !s.country.empty? && s.country.include?(i.country)
196
172
  return "tls" if Camada.present(i.tlsx) && s.tls.include?(i.tlsx)
197
- if (!s.paths_exact.empty? || !s.paths_prefix.empty? || !s.paths_regex.empty?) && blocked_path?(Snapshot.clean_path(i.path))
173
+ if (!s.paths_exact.empty? || !s.paths_prefix.empty? || !s.paths_regex.empty?) &&
174
+ Snapshot.path_hit?(forms, true) { |p| Snapshot.path_in?(s, p, s.paths_regex) }
198
175
  return "path"
199
176
  end
200
177
 
@@ -202,18 +179,17 @@ module Camada
202
179
  end
203
180
 
204
181
  # A v4 side list (allow or challenge). No tls axis: §A3's side meta has no tls key.
205
- def side(st, i, n4, w)
182
+ def side(st, i, n4, w, forms, deny)
206
183
  return nil if st.empty # the common v3 snapshot
207
184
  return "ip4" if n4 >= 0 && Snapshot.in_range4?(st.r4, n4)
208
185
  return "ip6" if !w.nil? && Snapshot.in_range6?(st.r6, st.n6, w)
209
186
  return "asn" if !i.asn.nil? && st.asn.include?(i.asn)
210
187
  return "country" if Camada.present(i.country) && st.country.include?(i.country)
211
188
 
212
- if !st.paths_exact.empty? || !st.paths_prefix.empty?
213
- p = Snapshot.clean_path(i.path)
214
- return "path" if st.paths_exact.include?(p)
215
- return "path" if !st.paths_prefix.empty? && Snapshot.prefix_hit?(st.paths_prefix, p)
189
+ if (!st.paths_exact.empty? || !st.paths_prefix.empty?) && Snapshot.path_hit?(forms, deny) { |p| Snapshot.path_in?(st, p) }
190
+ return "path"
216
191
  end
192
+
217
193
  nil
218
194
  end
219
195
  end
@@ -67,7 +67,8 @@ module Camada
67
67
 
68
68
  # The request a compiled condition reads. `ip6` is the parsed address words, or nil; `header`
69
69
  # is called with an already lower-cased name, absent where the tap cannot read headers.
70
- RuleRequest = Struct.new(:n4, :ip6, :asn, :country, :tlsx, :path, :ua, :header, keyword_init: true)
70
+ # `paths` is the request path's [raw, lit, full] forms (Snapshot.path_forms).
71
+ RuleRequest = Struct.new(:n4, :ip6, :asn, :country, :tlsx, :paths, :ua, :header, keyword_init: true)
71
72
 
72
73
  CompiledRule = Struct.new(:id, :action, :conds)
73
74
 
@@ -82,8 +83,8 @@ module Camada
82
83
  m ||= {}
83
84
  asn = (m["asn"] || []).to_set(&:to_i)
84
85
  country = (m["country"] || []).to_set
85
- exact = (m["pathsExact"] || []).to_set
86
- prefix = (m["pathsPrefix"] || []).to_set
86
+ exact = (m["pathsExact"] || []).to_set { |p| canon_path(p.to_s) }
87
+ prefix = (m["pathsPrefix"] || []).to_set { |p| dir_key(p.to_s) }
87
88
  empty = r4.empty? && r6.empty? && asn.empty? && country.empty? && exact.empty? && prefix.empty?
88
89
  RangeSet.new(r4: r4, r6: r6, n6: r6.length >> 3, asn: asn, country: country, paths_exact: exact, paths_prefix: prefix, empty: empty)
89
90
  end
@@ -137,8 +138,8 @@ module Camada
137
138
  # authored as JS regexes (the analyst validates them with `new RegExp`), so the JS spellings
138
139
  # Onigmo reads differently are translated first — see js_to_onigmo; \d \w \b are ASCII in
139
140
  # Ruby by default, as JS reads them.
140
- def self.compile_regex(pattern)
141
- Regexp.new(js_to_onigmo(pattern))
141
+ def self.compile_regex(pattern, options = 0)
142
+ Regexp.new(js_to_onigmo(pattern), options)
142
143
  rescue RegexpError, TypeError, ArgumentError, EncodingError
143
144
  nil
144
145
  end
@@ -207,6 +208,122 @@ module Camada
207
208
  false
208
209
  end
209
210
 
211
+ # ---------- paths (contracts §D3 "Path matching") ----------
212
+ # A port of edge-analyst src/blocklist.js canonPath / pathForms / pathHit / pathPred. Both sides
213
+ # of a comparison are canonicalised so a rule catches every spelling a framework routes to the
214
+ # same handler: query cut at ? or #; per UTF-8 byte, %XX decoded when printable ASCII other
215
+ # than / and % (%2F stays %2f, one pass), every other byte written as lower-case %xx; ASCII
216
+ # lower-cased; each segment cut at its first ;; empty segments dropped; . and .. resolved
217
+ # (`full`; `lit` skips that step). A deny matches on raw, lit or full; an exemption (allow
218
+ # side, skip rule) needs lit AND full. Works on bytes, so a binary PATH_INFO never raises.
219
+ PATH_HEX = "0123456789abcdef"
220
+ CANON_PATH = %r{\A(?:/(?!\.\.?(?:/|\z))[a-z0-9\-._~!$&'()*+,=:@]+)+\z} # already canonical: skips the byte walk
221
+
222
+ def self.hexv(c)
223
+ if c.between?(48, 57) then c - 48
224
+ elsif c.between?(97, 102) then c - 87
225
+ elsif c.between?(65, 70) then c - 55
226
+ else -1
227
+ end
228
+ end
229
+
230
+ def self.strip_query(raw)
231
+ p = raw.nil? || raw.empty? ? "/" : raw
232
+ q = p.b.index(/[?#]/)
233
+ q.nil? ? p : p.byteslice(0, q)
234
+ end
235
+
236
+ def self.canonical?(p) = p == "/" || (p.ascii_only? && CANON_PATH.match?(p))
237
+
238
+ def self.canon_path(raw, dots: true)
239
+ p = strip_query(raw)
240
+ return p if canonical?(p)
241
+
242
+ b = p.bytes
243
+ s = +""
244
+ i = 0
245
+ while i < b.length
246
+ c = b[i]
247
+ decoded = c == 37 && i + 2 < b.length && hexv(b[i + 1]) >= 0 && hexv(b[i + 2]) >= 0
248
+ if decoded
249
+ c = (hexv(b[i + 1]) * 16) + hexv(b[i + 2])
250
+ i += 2
251
+ end
252
+ if decoded && c == 47 then s << "%2f" # a decoded %2F is never a separator
253
+ elsif c < 0x21 || c > 0x7e || c == 37 then s << "%" << PATH_HEX[c >> 4] << PATH_HEX[c & 15]
254
+ else s << (c.between?(65, 90) ? c + 32 : c).chr
255
+ end
256
+ i += 1
257
+ end
258
+ out = []
259
+ s.split("/").each do |seg|
260
+ k = seg.index(";")
261
+ seg = seg[0, k] unless k.nil?
262
+ next if seg.empty? || (dots && seg == ".")
263
+
264
+ if dots && seg == ".."
265
+ out.pop
266
+ next
267
+ end
268
+ out << seg
269
+ end
270
+ "/#{out.join("/")}"
271
+ end
272
+
273
+ # [raw (query cut), lit, full] for one request path.
274
+ def self.path_forms(raw)
275
+ p = strip_query(raw)
276
+ return [p, p, p] if canonical?(p)
277
+
278
+ [p, canon_path(p, dots: false), canon_path(p)]
279
+ end
280
+
281
+ def self.path_dir(p) = p.end_with?("/") ? p : "#{p}/"
282
+
283
+ # A prefix entry or a starts_with value ending in / -> its canonical directory key ('/' stays '/').
284
+ def self.dir_key(v) = path_dir(canon_path(v))
285
+
286
+ # Walks '/' boundaries: /a/b tries /, /a/, /a/b/.
287
+ def self.prefix_hit?(prefixes, path)
288
+ d = path_dir(path).b
289
+ i = 0
290
+ until i.nil?
291
+ return true if prefixes.include?(d.byteslice(0, i + 1))
292
+
293
+ i = d.index("/", i + 1)
294
+ end
295
+ false
296
+ end
297
+
298
+ # One path form against a side's (or the block side's) exact, prefix and regex entries.
299
+ def self.path_in?(st, path, regexes = [])
300
+ return true if st.paths_exact.include?(path)
301
+ return true if !st.paths_prefix.empty? && prefix_hit?(st.paths_prefix, path)
302
+
303
+ regexes.any? { |rx| regex_hit?(rx, path) }
304
+ end
305
+
306
+ # Does the block hold for this request's path? deny = any spelling; an exemption = both canonical forms.
307
+ def self.path_hit?(forms, deny, &pred)
308
+ deny ? (pred.call(forms[0]) || pred.call(forms[1]) || pred.call(forms[2])) : (pred.call(forms[1]) && pred.call(forms[2]))
309
+ end
310
+
311
+ # One path condition -> a predicate over a single path form.
312
+ def self.path_pred(op, values)
313
+ case op
314
+ when "matches"
315
+ rx = compile_regex(values[0], Regexp::IGNORECASE)
316
+ ->(p) { !rx.nil? && regex_hit?(rx, p) }
317
+ when "starts_with"
318
+ v = values[0]
319
+ key = v.end_with?("/") ? dir_key(v) : canon_path(v)
320
+ ->(p) { text_hit? { path_dir(p).start_with?(key) } }
321
+ else
322
+ set = values.to_set { |v| canon_path(v) }
323
+ ->(p) { set.include?(p) }
324
+ end
325
+ end
326
+
210
327
  # ---------- custom rules (v5) ----------
211
328
 
212
329
  # The string one condition reads, or nil when this request cannot answer the field.
@@ -216,7 +333,6 @@ module Camada
216
333
  when "asn" then r.asn&.to_s
217
334
  when "country" then Camada.present(r.country)
218
335
  when "tlsx" then Camada.present(r.tlsx)
219
- when "path" then r.path
220
336
  when "ua" then Camada.present(r.ua)
221
337
  end
222
338
  # an entity-plane field (bot.verified, rule) answers nil: never true here
@@ -224,9 +340,14 @@ module Camada
224
340
 
225
341
  # One condition -> a predicate. `sets` yields this rule's (v4, v6) section pair per ip
226
342
  # condition, in condition order, so an ip condition consumes the next one.
227
- def self.compile_cond(c, sets)
343
+ def self.compile_cond(c, sets, deny = true)
228
344
  f = c["f"].to_s
229
345
  op = c["op"].to_s
346
+ if f == "path" # every path op reads the canonical forms (see "paths" above)
347
+ raw = c["v"]
348
+ pred = path_pred(op, raw.is_a?(Array) ? raw.map(&:to_s) : [raw.to_s])
349
+ return ->(r) { path_hit?(r.paths, deny, &pred) }
350
+ end
230
351
  negate = op == "is_not" || op == "not_in"
231
352
  # A header condition reads the request through the caller's getter. The name is lower-cased
232
353
  # once, here; a tap that cannot read headers (no getter) and a header the request does not
@@ -306,7 +427,7 @@ module Camada
306
427
  [k < v4.length ? v4[k].slice(1, v4[k].length - 1) : EMPTY, k < v6.length ? v6[k].slice(1, v6[k].length - 1) : EMPTY]
307
428
  end
308
429
  conds = begin
309
- (r["conds"] || []).map { |c| compile_cond(c, sets) }
430
+ (r["conds"] || []).map { |c| compile_cond(c, sets, action != "skip") }
310
431
  rescue StandardError
311
432
  next # a malformed rule is dropped, never enforced
312
433
  end
@@ -351,9 +472,9 @@ module Camada
351
472
  asn_bm: sec[8] || Zeros.new(131_072), asn_extra: sec.fetch(9, EMPTY),
352
473
  country: (meta["country"] || []).to_set,
353
474
  tls: (meta["tls"] || []).to_set,
354
- paths_exact: (meta["pathsExact"] || []).to_set,
355
- paths_prefix: (meta["pathsPrefix"] || []).to_set,
356
- paths_regex: (meta["pathsRegex"] || []).filter_map { |p| compile_regex(p) },
475
+ paths_exact: (meta["pathsExact"] || []).to_set { |p| canon_path(p.to_s) },
476
+ paths_prefix: (meta["pathsPrefix"] || []).to_set { |p| dir_key(p.to_s) },
477
+ paths_regex: (meta["pathsRegex"] || []).filter_map { |p| compile_regex(p, Regexp::IGNORECASE) },
357
478
  allow: range_set(sec.fetch(10, EMPTY), sec.fetch(11, EMPTY), meta["allow"]),
358
479
  challenge: range_set(sec.fetch(12, EMPTY), sec.fetch(13, EMPTY), meta["challenge"]),
359
480
  rules: compile_rules(meta, rule4, rule6)
@@ -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.0"
8
+ VERSION = "0.1.2"
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.0
4
+ version: 0.1.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-09-14 00:00:00.000000000 Z
11
+ date: 2026-10-04 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,