Class: MailerToGo::SPF::ChainAudit

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

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_chainObject (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

#hostnameObject (readonly)

Returns the value of attribute hostname.



53
54
55
# File 'lib/mailertogo/spf/chain_audit.rb', line 53

def hostname
  @hostname
end

#recordObject (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_spfObject (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

#termsObject (readonly)

Returns the value of attribute terms.



53
54
55
# File 'lib/mailertogo/spf/chain_audit.rb', line 53

def terms
  @terms
end

#totalObject (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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


135
# File 'lib/mailertogo/spf/chain_audit.rb', line 135

def capped? = @capped

#headroomObject

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

#limitObject



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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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?).

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


122
# File 'lib/mailertogo/spf/chain_audit.rb', line 122

def resolved? = @resolved

#runObject



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