Class: MailerToGo::SPF::ChainAudit
- Inherits:
-
Object
- Object
- MailerToGo::SPF::ChainAudit
- Defined in:
- lib/mailertogo/spf/chain_audit.rb
Overview
Prices a published SPF record against the RFC 7208 §4.6.4 lookup budget, term by term, and notes the chain defects found on the way.
Why this is not Authorization. That class answers "does this record authorise ME?", and §4.6.2 ends a receiver's evaluation at the first mechanism that matches — so it deliberately counts only the lookups spent up to the match. That is the right number for a gating decision (a record whose match lands on term 10 of an 11-term record passes at every real receiver, and calling it a permerror would be wrong) and the wrong number for describing the record itself, where the question is what it costs a receiver that has to evaluate ALL of it. That second number is what every SPF checker reports and the one people compare against, and the two can legitimately disagree about the same record — one authorises you at a cost of 10 while itself costing 11 and being broken for everybody past your include.
So: walk the whole tree, charge every querying term, and hand back the cost attached to the term that incurred it.
Bounded three ways, because this is the sort of thing that ends up behind an unauthenticated "check my domain" box resolving whatever a stranger typed: the §4.6.4 cap itself, a depth limit, and a hard CEILING past which we stop resolving — once a record is over budget the exact number no longer changes what anyone should do about it.
Reads DNS through the same injected resolver as everything else, so behind a CachingResolver an audit of a name you have already authorised is largely cache hits.
Constant Summary collapse
- LIMIT =
RFC 7208 §4.6.4 — 10 is legal, 11 permerrors. The same cap Authorization enforces; named here because this class reports against it.
Authorization::MAX_DNS_LOOKUPS
- MAX_DEPTH =
Belt-and-suspenders against a pathological tree, exactly as in the engine: the budget below is the real bound, this stops runaway recursion on a wide shallow record.
Authorization::MAX_DEPTH
- CEILING =
Stop resolving well past the cap. A record needing 20 lookups and one needing 200 are the same record to a receiver: permerror. Past this the total is reported as a floor (see #capped?) rather than chased.
2 * LIMIT
Instance Attribute Summary collapse
-
#duplicated_in_chain ⇒ Object
readonly
Returns the value of attribute duplicated_in_chain.
-
#hostname ⇒ Object
readonly
Returns the value of attribute hostname.
-
#record ⇒ Object
readonly
Returns the value of attribute record.
-
#targets_without_spf ⇒ Object
readonly
Returns the value of attribute targets_without_spf.
-
#terms ⇒ Object
readonly
Returns the value of attribute terms.
-
#total ⇒ Object
readonly
Returns the value of attribute total.
Class Method Summary collapse
-
.call(hostname:, resolver:, record: nil, term_class: nil) ⇒ Object
record — the record to price.
Instance Method Summary collapse
-
#apex_duplicated? ⇒ Boolean
Was there more than one v=spf1 record at the hostname itself? §4.5 makes that a permerror just as surely as duplicates inside the chain.
-
#capped? ⇒ Boolean
We stopped walking at CEILING: the record is far past the cap and the real total is higher than the one reported.
-
#headroom ⇒ Object
How many lookups are still available before the cap, or nil when we cannot say (a floor cannot answer "how much room is left", and neither can a record that does not exist).
-
#initialize(hostname:, resolver:, record: nil, term_class: nil) ⇒ ChainAudit
constructor
A new instance of ChainAudit.
- #limit ⇒ Object
-
#over_limit? ⇒ Boolean
More than §4.6.4 allows: 10 is legal, 11 permerrors.
-
#partial? ⇒ Boolean
Part of the chain did not resolve, so the total is a FLOOR.
-
#published? ⇒ Boolean
Is there a record here at all? False when the name publishes no v=spf1 record (or DNS did not answer — see #resolved?).
-
#resolved? ⇒ Boolean
Did DNS answer when we resolved the apex ourselves? Always true when the record was handed to us.
- #run ⇒ Object
Constructor Details
#initialize(hostname:, resolver:, record: nil, term_class: nil) ⇒ ChainAudit
Returns a new instance of ChainAudit.
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
# File 'lib/mailertogo/spf/chain_audit.rb', line 67 def initialize(hostname:, resolver:, record: nil, term_class: nil) @record = record&.to_s @hostname = Record.normalize_name(hostname) @resolver = resolver @term_class = term_class || Term @spent = 0 @total = nil @terms = [] @targets_without_spf = [] @duplicated_in_chain = [] # nil until we resolve the apex ourselves — "cannot say", not "no". @apex_duplicated = nil @resolved = true @partial = false @capped = false # The queried name guards a record that includes itself. @seen = Set.new([@hostname]) end |
Instance Attribute Details
#duplicated_in_chain ⇒ Object (readonly)
Returns the value of attribute duplicated_in_chain.
53 54 55 |
# File 'lib/mailertogo/spf/chain_audit.rb', line 53 def duplicated_in_chain @duplicated_in_chain end |
#hostname ⇒ Object (readonly)
Returns the value of attribute hostname.
53 54 55 |
# File 'lib/mailertogo/spf/chain_audit.rb', line 53 def hostname @hostname end |
#record ⇒ Object (readonly)
Returns the value of attribute record.
53 54 55 |
# File 'lib/mailertogo/spf/chain_audit.rb', line 53 def record @record end |
#targets_without_spf ⇒ Object (readonly)
Returns the value of attribute targets_without_spf.
53 54 55 |
# File 'lib/mailertogo/spf/chain_audit.rb', line 53 def targets_without_spf @targets_without_spf end |
#terms ⇒ Object (readonly)
Returns the value of attribute terms.
53 54 55 |
# File 'lib/mailertogo/spf/chain_audit.rb', line 53 def terms @terms end |
#total ⇒ Object (readonly)
Returns the value of attribute total.
53 54 55 |
# File 'lib/mailertogo/spf/chain_audit.rb', line 53 def total @total end |
Class Method Details
.call(hostname:, resolver:, record: nil, term_class: nil) ⇒ Object
record — the record to price. Pass nil to have the apex resolved from
DNS at hostname, which is the whole question ("what does
this domain's SPF cost?") asked in one call.
hostname — the name the record is published at. Guards a record that
includes itself, and is where a nil record is resolved from.
resolver — anything responding to #call(name); see Resolver.
term_class— a Term subclass to build the terms as, for a caller that
hangs its own copy off a term.
63 64 65 |
# File 'lib/mailertogo/spf/chain_audit.rb', line 63 def self.call(hostname:, resolver:, record: nil, term_class: nil) new(hostname: hostname, resolver: resolver, record: record, term_class: term_class).run end |
Instance Method Details
#apex_duplicated? ⇒ Boolean
Was there more than one v=spf1 record at the hostname itself? §4.5 makes that a permerror just as surely as duplicates inside the chain.
nil means "cannot say" rather than "no": the record was handed to us, so we never looked at the apex (or DNS did not answer). Only a caller that let us resolve the apex gets a true/false here — the one that resolved it already knows, and should report from what it saw rather than ask us.
144 |
# File 'lib/mailertogo/spf/chain_audit.rb', line 144 def apex_duplicated? = @apex_duplicated |
#capped? ⇒ Boolean
We stopped walking at CEILING: the record is far past the cap and the real total is higher than the one reported. Same treatment as partial — the number is a floor — for a different reason.
135 |
# File 'lib/mailertogo/spf/chain_audit.rb', line 135 def capped? = @capped |
#headroom ⇒ Object
How many lookups are still available before the cap, or nil when we cannot say (a floor cannot answer "how much room is left", and neither can a record that does not exist).
149 150 151 152 153 |
# File 'lib/mailertogo/spf/chain_audit.rb', line 149 def headroom return nil if partial? || capped? || !published? [LIMIT - @total.to_i, 0].max end |
#limit ⇒ Object
113 |
# File 'lib/mailertogo/spf/chain_audit.rb', line 113 def limit = LIMIT |
#over_limit? ⇒ Boolean
More than §4.6.4 allows: 10 is legal, 11 permerrors.
125 |
# File 'lib/mailertogo/spf/chain_audit.rb', line 125 def over_limit? = @total.to_i > LIMIT |
#partial? ⇒ Boolean
Part of the chain did not resolve, so the total is a FLOOR. Never report "this fits" from a count we could not finish — the direction that is dangerous to get wrong is the reassuring one.
130 |
# File 'lib/mailertogo/spf/chain_audit.rb', line 130 def partial? = @partial |
#published? ⇒ Boolean
Is there a record here at all? False when the name publishes no v=spf1 record (or DNS did not answer — see #resolved?).
117 |
# File 'lib/mailertogo/spf/chain_audit.rb', line 117 def published? = !@record.to_s.empty? |
#resolved? ⇒ Boolean
Did DNS answer when we resolved the apex ourselves? Always true when the record was handed to us. A resolver hiccup is not "this domain has no SPF", and the two must never be reported as the same thing.
122 |
# File 'lib/mailertogo/spf/chain_audit.rb', line 122 def resolved? = @resolved |
#run ⇒ Object
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 |
# File 'lib/mailertogo/spf/chain_audit.rb', line 86 def run resolve_apex if @record.nil? parsed = Record.parse_terms(@record, term_class: @term_class) # §6.1 — a record containing `all` ignores its redirect= outright, # wherever in the record the redirect sits. terminal = parsed.any?(&:all?) seen_all = false parsed.each do |term| unreachable = seen_all || (term.redirect? && terminal) # Rebuilt rather than mutated: unreachability is a fact about the # term's POSITION, which only this loop knows, and a term is frozen. term = @term_class.new(raw: term.raw, position: term.position, unreachable: unreachable) cost = unreachable ? 0 : charge(term, depth: 0) @terms << term.priced(lookups: cost, running_total: @spent) seen_all ||= term.all? end # nil, not 0, when there is nothing to price. 0 is a legitimate total — # `v=spf1 -all` costs exactly that — so using it for "no record" lets a # caller that skipped #published? report "this record costs 0 lookups" # about a domain with no record at all. @total = published? ? @spent : nil self end |