camada 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +199 -0
- data/lib/camada/beacon_js.rb +10 -0
- data/lib/camada/body_proxy.rb +48 -0
- data/lib/camada/challenge/format.rb +108 -0
- data/lib/camada/challenge/page.rb +107 -0
- data/lib/camada/challenge/verify.rb +68 -0
- data/lib/camada/config.rb +38 -0
- data/lib/camada/constants.rb +35 -0
- data/lib/camada/engine.rb +364 -0
- data/lib/camada/env.rb +47 -0
- data/lib/camada/events/build.rb +82 -0
- data/lib/camada/events/queue.rb +184 -0
- data/lib/camada/guarded.rb +34 -0
- data/lib/camada/ip.rb +80 -0
- data/lib/camada/ipparse.rb +100 -0
- data/lib/camada/rack.rb +205 -0
- data/lib/camada/railtie.rb +17 -0
- data/lib/camada/redact.rb +64 -0
- data/lib/camada/snapshot/client.rb +199 -0
- data/lib/camada/snapshot/match.rb +221 -0
- data/lib/camada/snapshot/parse.rb +363 -0
- data/lib/camada/transport.rb +58 -0
- data/lib/camada/version.rb +10 -0
- data/lib/camada.rb +99 -0
- metadata +74 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../config"
|
|
5
|
+
require_relative "../constants"
|
|
6
|
+
require_relative "../guarded"
|
|
7
|
+
require_relative "../transport"
|
|
8
|
+
require_relative "match"
|
|
9
|
+
require_relative "parse"
|
|
10
|
+
|
|
11
|
+
module Camada
|
|
12
|
+
module Snapshot
|
|
13
|
+
# Client: the single-tenant port of the edge collector's snapshot lifecycle over the
|
|
14
|
+
# GET /snapshot contract (ported from @camada/core src/snapshot/client.ts):
|
|
15
|
+
# 200 [u32 LE meta-length][meta JSON][BLK container] + etag + x-camada-config
|
|
16
|
+
# 304 nothing changed; config header repeated (config refreshes every poll for free)
|
|
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
|
|
19
|
+
# poll cadence, not per request); any error keeps the previous snapshot; cold = fail open.
|
|
20
|
+
# Timers are threads here: timer mode runs one thread per client sleeping on a condition
|
|
21
|
+
# variable; lazy mode kicks a one-shot thread from ensure_fresh so the request path never
|
|
22
|
+
# waits on the network. Ruby has no register_at_fork, so every entry point compares
|
|
23
|
+
# Process.pid with the pid that built the client and starts over in a forked worker.
|
|
24
|
+
COLD = MatchResult.new(reason: "cold") # never loaded yet: fail open, mirrors the collector
|
|
25
|
+
NONE = MatchResult.new
|
|
26
|
+
|
|
27
|
+
class Client
|
|
28
|
+
attr_accessor :transport
|
|
29
|
+
attr_reader :url, :token, :matcher, :config, :refresh_s, :mode, :sdk, :snapshot_version, :timeout_s, :poll_thread
|
|
30
|
+
|
|
31
|
+
def initialize(url, token, refresh_s: nil, timeout_s: 3.0, mode: :timer, transport: nil, sdk: nil,
|
|
32
|
+
snapshot_version: DEFAULT_SNAPSHOT_VERSION)
|
|
33
|
+
# refresh_s: leave unset and the server's poll_seconds steers it; set it and it is pinned.
|
|
34
|
+
# sdk: '<package>/<version>', sent as x-camada-sdk on every poll (SDK-03).
|
|
35
|
+
# snapshot_version: 5 asks for the custom rules too; 4 the sides only; 3 opts out of both.
|
|
36
|
+
@url = url
|
|
37
|
+
@token = token
|
|
38
|
+
@timeout_s = timeout_s
|
|
39
|
+
@mode = mode
|
|
40
|
+
@sdk = sdk
|
|
41
|
+
@snapshot_version = snapshot_version
|
|
42
|
+
@transport = transport || Transport::DEFAULT
|
|
43
|
+
@matcher = nil
|
|
44
|
+
@config = nil
|
|
45
|
+
@refresh_s = refresh_s.nil? ? DEFAULT_REFRESH_S : refresh_s.to_f
|
|
46
|
+
@pinned = !refresh_s.nil?
|
|
47
|
+
@etag = nil
|
|
48
|
+
@loaded_at = nil
|
|
49
|
+
fresh_state!
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Threads do not survive fork (Puma cluster preload_app!, Unicorn, Passenger): forget the
|
|
53
|
+
# parent's, then re-arm the timer so the child polls on its own (lazy mode refreshes from
|
|
54
|
+
# the request path anyway).
|
|
55
|
+
def after_fork!
|
|
56
|
+
fresh_state!
|
|
57
|
+
start if @mode == :timer
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def start
|
|
61
|
+
ensure_fresh
|
|
62
|
+
return if @mode != :timer || !@poll_thread.nil?
|
|
63
|
+
|
|
64
|
+
@stopped = false
|
|
65
|
+
@poll_thread = Thread.new { run }
|
|
66
|
+
@poll_thread.name = "camada-snapshot"
|
|
67
|
+
@poll_thread.report_on_exception = false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def stop
|
|
71
|
+
@stop_m.synchronize do
|
|
72
|
+
@stopped = true
|
|
73
|
+
@stop_cv.broadcast
|
|
74
|
+
end
|
|
75
|
+
@poll_thread = nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# 0.9 x refresh so a timer tick arriving at ~refresh-ε still refreshes; a full-interval
|
|
79
|
+
# comparison makes every other tick a no-op (effective cadence 2x).
|
|
80
|
+
def stale?
|
|
81
|
+
@loaded_at.nil? || monotonic - @loaded_at > @refresh_s * 0.9
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Kicks a refresh when stale; never blocks the request path, never raises.
|
|
85
|
+
def ensure_fresh
|
|
86
|
+
check_fork!
|
|
87
|
+
return if !stale? || @loading.locked?
|
|
88
|
+
|
|
89
|
+
t = Thread.new { refresh }
|
|
90
|
+
t.name = "camada-snapshot-load"
|
|
91
|
+
t.report_on_exception = false
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# One synchronous poll (single in-flight): what the threads call, and what tests and warm-ups call directly.
|
|
96
|
+
def refresh
|
|
97
|
+
check_fork!
|
|
98
|
+
lock = @loading # bound once: after_fork! swaps the attribute
|
|
99
|
+
return unless lock.try_lock
|
|
100
|
+
|
|
101
|
+
begin
|
|
102
|
+
load_once
|
|
103
|
+
rescue StandardError => e # a poll that can never succeed must not be silent, nor fatal
|
|
104
|
+
Guarded.log_rate_limited(e)
|
|
105
|
+
ensure
|
|
106
|
+
lock.unlock
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Cold (never loaded) and no-snapshot both fail open, mirroring the edge collector.
|
|
111
|
+
def verdict(i)
|
|
112
|
+
return COLD if @loaded_at.nil?
|
|
113
|
+
|
|
114
|
+
m = @matcher
|
|
115
|
+
m ? m.match(i) : NONE
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def fresh_state!
|
|
121
|
+
@pid = Process.pid
|
|
122
|
+
@loading = Mutex.new
|
|
123
|
+
@stop_m = Mutex.new
|
|
124
|
+
@stop_cv = ConditionVariable.new
|
|
125
|
+
@stopped = false
|
|
126
|
+
@poll_thread = nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def check_fork!
|
|
130
|
+
after_fork! if Process.pid != @pid
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
134
|
+
|
|
135
|
+
# Sleeps one cadence; true when stop was called meanwhile.
|
|
136
|
+
def wait_stop(seconds)
|
|
137
|
+
@stop_m.synchronize do
|
|
138
|
+
@stop_cv.wait(@stop_m, seconds) unless @stopped
|
|
139
|
+
@stopped
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def run
|
|
144
|
+
ensure_fresh until wait_stop(@refresh_s)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def load_once
|
|
148
|
+
headers = { "authorization" => "Bearer #{@token}", "accept-encoding" => "gzip" }
|
|
149
|
+
headers["if-none-match"] = @etag if @etag
|
|
150
|
+
headers["x-camada-sdk"] = @sdk if @sdk
|
|
151
|
+
# a tenant without that container is answered with the next one down
|
|
152
|
+
headers["x-camada-snapshot"] = @snapshot_version.to_s if @snapshot_version > 3
|
|
153
|
+
res = @transport.call(HttpRequest.new(method: "GET", url: @url, headers: headers, body: nil, timeout_s: @timeout_s))
|
|
154
|
+
return unless [200, 204, 304].include?(res.status) # 401/5xx/network: keep what we have
|
|
155
|
+
|
|
156
|
+
@loaded_at = monotonic
|
|
157
|
+
read_config(res.headers["x-camada-config"])
|
|
158
|
+
return if res.status == 304
|
|
159
|
+
|
|
160
|
+
if res.status == 204 # no snapshot published: enforce nothing
|
|
161
|
+
@matcher = nil
|
|
162
|
+
@etag = nil
|
|
163
|
+
return
|
|
164
|
+
end
|
|
165
|
+
body = res.body
|
|
166
|
+
raise ArgumentError, "camada: truncated snapshot frame" if body.bytesize < 4
|
|
167
|
+
|
|
168
|
+
meta_len = body.unpack1("V")
|
|
169
|
+
raise ArgumentError, "camada: truncated snapshot frame" if 4 + meta_len > body.bytesize
|
|
170
|
+
|
|
171
|
+
meta = JSON.parse(body.byteslice(4, meta_len))
|
|
172
|
+
raise ArgumentError, "camada: snapshot meta is not an object" unless meta.is_a?(Hash)
|
|
173
|
+
|
|
174
|
+
# The server ships the v3, v4 and v5 bodies of one publish under the SAME meta.version and
|
|
175
|
+
# different etags, so version alone cannot say "nothing changed".
|
|
176
|
+
etag = res.headers["etag"]
|
|
177
|
+
return if @matcher && meta["version"].to_s == @matcher.snap.version && !etag.nil? && etag == @etag
|
|
178
|
+
|
|
179
|
+
# parse_snapshot raises on corrupt data -> caught by refresh, previous kept
|
|
180
|
+
@matcher = Matcher.new(Snapshot.parse_snapshot(Words.new(body, 4 + meta_len), meta))
|
|
181
|
+
@etag = etag
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def read_config(raw)
|
|
185
|
+
return if raw.nil? || raw.empty?
|
|
186
|
+
|
|
187
|
+
cfg = Camada.parse_json(raw)
|
|
188
|
+
return unless cfg.is_a?(Hash) # not an object, or not JSON: keep the previous config
|
|
189
|
+
|
|
190
|
+
@config = cfg
|
|
191
|
+
# the server steers the poll cadence per tenant (its cost lever) unless the client pinned one
|
|
192
|
+
secs = Float(cfg["poll_seconds"] || 0, exception: false)
|
|
193
|
+
return if @pinned || secs.nil? || !secs.finite? || secs < 5 || secs == @refresh_s # JSON admits 1e999
|
|
194
|
+
|
|
195
|
+
@refresh_s = secs
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "parse"
|
|
4
|
+
|
|
5
|
+
module Camada
|
|
6
|
+
module Snapshot
|
|
7
|
+
# Matcher: sub-millisecond checks over a parsed Snap, ported from @camada/core
|
|
8
|
+
# src/snapshot/match.ts (itself from edge-analyst src/blocklist.js). Matching is fully
|
|
9
|
+
# synchronous and allocation-light. The original's per-instance scratch request is not ported:
|
|
10
|
+
# a JS isolate runs one match() at a time, but here one Matcher serves every request thread,
|
|
11
|
+
# so the rule loop reads a RuleRequest built per call.
|
|
12
|
+
#
|
|
13
|
+
# Outcome order is contract (contracts §D3, fixtures pin it): the tenant's ordered custom rules
|
|
14
|
+
# first (first match wins, the order IS the precedence), then allow -> block -> challenge.
|
|
15
|
+
# Within each side the axis order is ip4 -> ip6 -> asn -> country -> tls -> path.
|
|
16
|
+
# At the SDK position only ip, path, ua and the request headers are usually known;
|
|
17
|
+
# asn/country/tlsx entries and conditions then simply never match — that is the documented,
|
|
18
|
+
# honest enforcement scope (fail open, never guess).
|
|
19
|
+
MatchInput = Struct.new(
|
|
20
|
+
:ip, :asn, :country, :tlsx, :path,
|
|
21
|
+
:ua, # v5 rules read it; the three sides never do
|
|
22
|
+
:header, # v5 header conditions read it, always with a lower-cased name
|
|
23
|
+
keyword_init: true
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
class MatchResult
|
|
27
|
+
attr_reader :block, :challenge, :allowed, :warn, :action, :rule, :reason, :version
|
|
28
|
+
|
|
29
|
+
# allowed: true for skip (which absorbed the old allow) and for the allow side;
|
|
30
|
+
# action: the action of the rule that decided, nil when a side did;
|
|
31
|
+
# rule: the rule id, present only when reason is 'rule';
|
|
32
|
+
# reason: ip4 | ip6 | asn | country | tls | path | rule | cold.
|
|
33
|
+
def initialize(block: false, challenge: false, allowed: false, warn: false, action: nil, rule: nil, reason: nil, version: nil)
|
|
34
|
+
@block = block
|
|
35
|
+
@challenge = challenge
|
|
36
|
+
@allowed = allowed
|
|
37
|
+
@warn = warn
|
|
38
|
+
@action = action
|
|
39
|
+
@rule = rule
|
|
40
|
+
@reason = reason
|
|
41
|
+
@version = version
|
|
42
|
+
freeze
|
|
43
|
+
end
|
|
44
|
+
end
|
|
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
|
+
# A rule decided this request (§D3): at most one of allowed / block / challenge / warn is
|
|
64
|
+
# true, `reason` is 'rule', and `rule` names the id the adapters stamp on the event.
|
|
65
|
+
def self.rule_result(rule, version)
|
|
66
|
+
a = rule.action
|
|
67
|
+
MatchResult.new(
|
|
68
|
+
block: a == "block", challenge: a == "challenge", allowed: a == "skip", warn: a == "warn",
|
|
69
|
+
action: a, rule: rule.id, reason: "rule", version: version
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class Matcher
|
|
74
|
+
attr_reader :snap
|
|
75
|
+
|
|
76
|
+
def initialize(snap)
|
|
77
|
+
@snap = snap
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def match(i)
|
|
81
|
+
s = @snap
|
|
82
|
+
ip = i.ip || ""
|
|
83
|
+
n4 = -1
|
|
84
|
+
w = nil
|
|
85
|
+
unless ip.empty?
|
|
86
|
+
if ip.include?(":")
|
|
87
|
+
w = IpParse.parse_ip6(ip)
|
|
88
|
+
else
|
|
89
|
+
n4 = IpParse.parse_ip4(ip)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
unless s.rules.empty?
|
|
93
|
+
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)
|
|
95
|
+
s.rules.each do |rule| # the order IS the precedence (§A4): first match wins
|
|
96
|
+
return Snapshot.rule_result(rule, s.version) if rule.conds.all? { |cond| cond.call(r) }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
reason = side(s.allow, i, n4, w)
|
|
100
|
+
return MatchResult.new(allowed: true, reason: reason, version: s.version) if reason
|
|
101
|
+
|
|
102
|
+
reason = block_side(i, n4, w)
|
|
103
|
+
return MatchResult.new(block: true, reason: reason, version: s.version) if reason
|
|
104
|
+
|
|
105
|
+
reason = side(s.challenge, i, n4, w)
|
|
106
|
+
return MatchResult.new(challenge: true, reason: reason, version: s.version) if reason
|
|
107
|
+
|
|
108
|
+
MatchResult.new(version: s.version)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def blocked4?(n)
|
|
114
|
+
s = @snap
|
|
115
|
+
b = n >> 8
|
|
116
|
+
return false if (s.bm4[b >> 5] >> (b & 31)) & 1 == 0
|
|
117
|
+
|
|
118
|
+
hi = n >> 16
|
|
119
|
+
left = s.idx4[hi]
|
|
120
|
+
right = s.idx4[hi + 1] - 1
|
|
121
|
+
left -= 1 if left > 0
|
|
122
|
+
return false if right < left
|
|
123
|
+
|
|
124
|
+
s4 = s.s4
|
|
125
|
+
while left < right
|
|
126
|
+
m = (left + right + 1) >> 1
|
|
127
|
+
if s4[m] <= n
|
|
128
|
+
left = m
|
|
129
|
+
else
|
|
130
|
+
right = m - 1
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
n.between?(s4[left], s.e4[left])
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def words_at(a, o) = [a[o], a[o + 1], a[o + 2], a[o + 3]]
|
|
137
|
+
|
|
138
|
+
def blocked6?(w)
|
|
139
|
+
s = @snap
|
|
140
|
+
b = w[0] >> 8
|
|
141
|
+
return false if (s.bm6[b >> 5] >> (b & 31)) & 1 == 0
|
|
142
|
+
|
|
143
|
+
left = 0
|
|
144
|
+
right = s.n6 - 1
|
|
145
|
+
return false if right < 0
|
|
146
|
+
|
|
147
|
+
s6 = s.s6
|
|
148
|
+
while left < right
|
|
149
|
+
m = (left + right + 1) >> 1
|
|
150
|
+
if (words_at(s6, m * 4) <=> w) <= 0
|
|
151
|
+
left = m
|
|
152
|
+
else
|
|
153
|
+
right = m - 1
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
o = left * 4
|
|
157
|
+
(words_at(s6, o) <=> w) <= 0 && (w <=> words_at(s.e6, o)) <= 0
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def blocked_asn?(asn)
|
|
161
|
+
s = @snap
|
|
162
|
+
return (s.asn_bm[asn >> 5] >> (asn & 31)) & 1 != 0 if asn < 4_194_304
|
|
163
|
+
|
|
164
|
+
extra = s.asn_extra
|
|
165
|
+
left = 0
|
|
166
|
+
right = extra.length - 1
|
|
167
|
+
while left <= right
|
|
168
|
+
m = (left + right) >> 1
|
|
169
|
+
v = extra[m]
|
|
170
|
+
return true if v == asn
|
|
171
|
+
|
|
172
|
+
if v < asn
|
|
173
|
+
left = m + 1
|
|
174
|
+
else
|
|
175
|
+
right = m - 1
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
false
|
|
179
|
+
end
|
|
180
|
+
|
|
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
|
+
# The block side: v3 sections plus the top-level meta.
|
|
190
|
+
def block_side(i, n4, w)
|
|
191
|
+
s = @snap
|
|
192
|
+
return "ip4" if n4 >= 0 && blocked4?(n4)
|
|
193
|
+
return "ip6" if !w.nil? && blocked6?(w)
|
|
194
|
+
return "asn" if !i.asn.nil? && blocked_asn?(i.asn)
|
|
195
|
+
return "country" if Camada.present(i.country) && !s.country.empty? && s.country.include?(i.country)
|
|
196
|
+
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))
|
|
198
|
+
return "path"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# 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)
|
|
206
|
+
return nil if st.empty # the common v3 snapshot
|
|
207
|
+
return "ip4" if n4 >= 0 && Snapshot.in_range4?(st.r4, n4)
|
|
208
|
+
return "ip6" if !w.nil? && Snapshot.in_range6?(st.r6, st.n6, w)
|
|
209
|
+
return "asn" if !i.asn.nil? && st.asn.include?(i.asn)
|
|
210
|
+
return "country" if Camada.present(i.country) && st.country.include?(i.country)
|
|
211
|
+
|
|
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)
|
|
216
|
+
end
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|