mailauth 0.3.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.
@@ -0,0 +1,92 @@
1
+ require "dnsruby"
2
+ require "ipaddr"
3
+
4
+ module MailAuth
5
+ # DNS for authentication. Injectable so tests can stand a hash in for a network.
6
+ # NotFound vs Timeout/ServerFailure matters: SPF counts a void lookup against a
7
+ # limit but abandons evaluation when the server didn't answer. Dnsruby rather
8
+ # than Resolv, which discards non-timeout rcodes and collapses SERVFAIL/REFUSED
9
+ # into the same empty answer as NXDOMAIN.
10
+ class Resolver
11
+ class Error < StandardError; end
12
+ class NotFound < Error; end # NXDOMAIN or NODATA
13
+ class Timeout < Error; end
14
+ class ServerFailure < Error; end # SERVFAIL, REFUSED, or anything else we couldn't tell
15
+
16
+ QUERY_TIMEOUT = 5
17
+
18
+ def initialize(timeout: QUERY_TIMEOUT)
19
+ @timeout = timeout
20
+ end
21
+
22
+ # TXT character-strings are joined into one value (RFC 7208 §3.3).
23
+ def txt(name)
24
+ records("TXT", name).map { |record| record.strings.join }
25
+ end
26
+
27
+ def a(name)
28
+ records("A", name).map { |record| record.address.to_s }
29
+ end
30
+
31
+ # Dnsruby prints IPv6 in capitals; RFC 5952 §4.3 asks for lower case.
32
+ def aaaa(name)
33
+ records("AAAA", name).map { |record| record.address.to_s.downcase }
34
+ end
35
+
36
+ def mx(name)
37
+ records("MX", name).sort_by(&:preference).map { |record| record.exchange.to_s }
38
+ end
39
+
40
+ # Dnsruby puts the question name in `name` and the host in `domainname`.
41
+ def ptr(address)
42
+ records("PTR", IPAddr.new(address).reverse).map { |record| record.domainname.to_s }
43
+ end
44
+
45
+ private
46
+ # Filter by type: a CNAME answer carries the alias beside the records it leads to.
47
+ def records(type, name)
48
+ answers = resolver.query(name.to_s, type).answer.select { |record| record.type == type }
49
+
50
+ if answers.any?
51
+ answers
52
+ else
53
+ raise NotFound, "no #{type} records for #{name}"
54
+ end
55
+ rescue Dnsruby::NXDomain
56
+ raise NotFound, "no such name #{name}"
57
+ rescue Dnsruby::ResolvTimeout
58
+ # Descends from Timeout::Error, not Dnsruby::ResolvError.
59
+ raise Timeout, "timed out resolving #{type} #{name}"
60
+ rescue Dnsruby::ResolvError => error
61
+ # Fall through to ServerFailure, never NotFound: "we could not tell"
62
+ # must not become "there is nothing there".
63
+ raise ServerFailure, "couldn't resolve #{type} #{name}: #{reason(error)}"
64
+ end
65
+
66
+ # Dnsruby rcode classes often carry no message (message == class name).
67
+ # `class.name` is nil on an anonymous class — don't call methods on that.
68
+ def reason(error)
69
+ rcode = error.class.name&.split("::")&.last
70
+
71
+ case
72
+ when rcode.nil? then error.message
73
+ when error.message == error.class.name then rcode
74
+ else "#{rcode}, #{error.message}"
75
+ end
76
+ end
77
+
78
+ def resolver
79
+ @resolver ||= Dnsruby::Resolver.new(**options).tap do |dns|
80
+ dns.dnssec = false
81
+ # Dnsruby caches by default in a process-wide store.
82
+ dns.do_caching = false
83
+ dns.query_timeout = @timeout
84
+ end
85
+ end
86
+
87
+ # Override in tests to point at a loopback nameserver.
88
+ def options
89
+ {}
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,100 @@
1
+ module MailAuth
2
+ # The vocabulary, matching what RFC 8601 registers so a verdict travels
3
+ # without translation.
4
+ module Status
5
+ PASS = "pass"
6
+ FAIL = "fail"
7
+ SOFTFAIL = "softfail"
8
+ NEUTRAL = "neutral"
9
+ NONE = "none"
10
+ TEMPERROR = "temperror"
11
+ PERMERROR = "permerror"
12
+ end
13
+
14
+ # `fail` says the check failed; `policy` says what the domain asked us to do
15
+ # about it. Folding those together is a consumer's decision, not ours.
16
+ DmarcResult = Data.define(:status, :policy, :pct, :alignment, :record) do
17
+ def initialize(status:, policy: nil, pct: nil, alignment: nil, record: nil)
18
+ super
19
+ end
20
+ end
21
+
22
+ SpfResult = Data.define(:status, :domain, :comment, :lookups) do
23
+ def initialize(status:, domain: nil, comment: nil, lookups: 0)
24
+ super
25
+ end
26
+ end
27
+
28
+ # One per DKIM-Signature header, whatever each one's fate.
29
+ Signature = Data.define(:status, :domain, :selector, :algorithm, :canonicalization, :comment, :aligned, :under_sized) do
30
+ def initialize(status:, domain: nil, selector: nil, algorithm: nil, canonicalization: nil, comment: nil, aligned: false, under_sized: nil)
31
+ super
32
+ end
33
+ end
34
+
35
+ DkimResult = Data.define(:signatures) do
36
+ # Precedence, not position: one good signature vindicates a message however
37
+ # many broken ones travel beside it.
38
+ PRECEDENCE = [ Status::PASS, Status::FAIL, Status::TEMPERROR, Status::PERMERROR, Status::NEUTRAL, Status::NONE ].freeze
39
+
40
+ def status
41
+ PRECEDENCE.find { |status| signatures.any? { |signature| signature.status == status } } || Status::NONE
42
+ end
43
+
44
+ def passing_domains
45
+ signatures.select { |signature| signature.status == Status::PASS }.map(&:domain).compact
46
+ end
47
+ end
48
+
49
+ # One ARC-Seal and one ARC-Message-Signature, reported apart because RFC 8617
50
+ # places no requirement that a set sign both with the same domain or selector.
51
+ ArcSeal = Data.define(:status, :domain, :selector, :algorithm, :comment) do
52
+ def initialize(status:, domain: nil, selector: nil, algorithm: nil, comment: nil)
53
+ super
54
+ end
55
+ end
56
+
57
+ ArcSignature = Data.define(:status, :domain, :selector, :algorithm, :comment) do
58
+ def initialize(status:, domain: nil, selector: nil, algorithm: nil, comment: nil)
59
+ super
60
+ end
61
+ end
62
+
63
+ # One ARC Set (RFC 8617 §4.2): what one hop sealed, and what it asserted about
64
+ # the message when it did. `claimed` is that hop's own account of the message's
65
+ # authentication, which a valid chain proves unmodified since sealing and
66
+ # proves nothing else about.
67
+ ArcSet = Data.define(:instance, :chain_status, :seal, :message_signature, :authentication_results, :claimed) do
68
+ def initialize(instance:, chain_status: nil, seal: nil, message_signature: nil, authentication_results: nil, claimed: {})
69
+ super
70
+ end
71
+ end
72
+
73
+ # Whether the chain holds, and who sealed it — never whether any of them is to
74
+ # be believed. A chain is cheap to forge over a message of one's own, so trust
75
+ # is the caller's decision and deliberately has no answer here.
76
+ ArcResult = Data.define(:status, :sets, :oldest_pass, :comment) do
77
+ def initialize(status:, sets: [], oldest_pass: nil, comment: nil)
78
+ super
79
+ end
80
+
81
+ def newest
82
+ sets.max_by(&:instance)
83
+ end
84
+
85
+ # Oldest hop first, the order they sealed in.
86
+ def sealers
87
+ sets.sort_by(&:instance).filter_map { |set| set.seal&.domain }
88
+ end
89
+
90
+ def intact?
91
+ status == Status::PASS
92
+ end
93
+ end
94
+
95
+ Result = Data.define(:spf, :dkim, :dmarc, :arc, :authentication_results) do
96
+ def initialize(spf:, dkim:, dmarc:, authentication_results:, arc: nil)
97
+ super
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,111 @@
1
+ module MailAuth
2
+ module Spf
3
+ # RFC 7208 §4.6.4's allowance per evaluation, spent one query at a time.
4
+ # Ten lookups caps a stranger's record chaining `include`s forever; two
5
+ # voids catches a record whose targets mostly don't exist.
6
+ class Budget
7
+ MAX_LOOKUPS = 10
8
+ MAX_VOID_LOOKUPS = 2
9
+ MAX_NAME_LENGTH = 253
10
+ MAX_LABEL_LENGTH = 63
11
+
12
+ attr_reader :lookups
13
+
14
+ # `charging: false` lets the opening record fetch through free: it belongs
15
+ # to no mechanism, so nobody is billed for it.
16
+ def initialize(resolver, charging: true)
17
+ @resolver = resolver
18
+ @charging = charging
19
+ @lookups = 0
20
+ @voids = 0
21
+ end
22
+
23
+ def txt(name)
24
+ query(:txt, name)
25
+ end
26
+
27
+ def a(name)
28
+ query(:a, name)
29
+ end
30
+
31
+ def aaaa(name)
32
+ query(:aaaa, name)
33
+ end
34
+
35
+ def mx(name)
36
+ query(:mx, name)
37
+ end
38
+
39
+ # Takes the client address, not a name: the reverse zone is the
40
+ # resolver's business.
41
+ def ptr(address)
42
+ resolve(:ptr, address)
43
+ end
44
+
45
+ # `mx` and `ptr` fan out into an address lookup per name. RFC 7208 bounds
46
+ # that separately, so it gets its own allowance.
47
+ def branch
48
+ Budget.new(@resolver)
49
+ end
50
+
51
+ private
52
+ # A name that can't be put on the wire isn't an error — treated as a
53
+ # name that simply isn't there. Length alone doesn't make one: an
54
+ # over-long name is shortened and then asked for.
55
+ def query(type, name)
56
+ name = truncate(name)
57
+
58
+ if queryable?(name)
59
+ resolve(type, name)
60
+ else
61
+ spend
62
+ void
63
+ end
64
+ end
65
+
66
+ # RFC 7208 §7.3: a macro expansion past 253 octets loses whole labels
67
+ # from the left until it fits. The query is still made, so this costs a
68
+ # lookup and never a void.
69
+ def truncate(name)
70
+ name = name.to_s
71
+ name = name.split(".", 2).last while name.length > MAX_NAME_LENGTH && name.include?(".")
72
+ name
73
+ end
74
+
75
+ def resolve(type, name)
76
+ spend
77
+ @resolver.public_send(type, name)
78
+ rescue Resolver::NotFound
79
+ void
80
+ rescue Resolver::Timeout, Resolver::ServerFailure => error
81
+ raise TempError, error.message
82
+ end
83
+
84
+ def spend
85
+ if @charging
86
+ @lookups += 1
87
+ raise PermError, "more than #{MAX_LOOKUPS} DNS lookups" if @lookups > MAX_LOOKUPS
88
+ else
89
+ @charging = true
90
+ end
91
+ end
92
+
93
+ def void
94
+ @voids += 1
95
+
96
+ if @voids > MAX_VOID_LOOKUPS
97
+ raise PermError, "more than #{MAX_VOID_LOOKUPS} void DNS lookups"
98
+ else
99
+ []
100
+ end
101
+ end
102
+
103
+ def queryable?(name)
104
+ labels = name.to_s.chomp(".").split(".", -1)
105
+
106
+ name.to_s.length <= MAX_NAME_LENGTH && !labels.empty? &&
107
+ labels.all? { |label| (1..MAX_LABEL_LENGTH).cover?(label.length) }
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,121 @@
1
+ module MailAuth
2
+ module Spf
3
+ # RFC 7208 §7. The little templating language a record uses to build a name
4
+ # out of the message in front of it — `exists:%{ir}.%{d}` asks a question
5
+ # about this particular client rather than about the domain in general.
6
+ module Macro
7
+ # Whatever a transformer splits on, the pieces are rejoined with dots —
8
+ # the result is going into a domain name.
9
+ DELIMITERS = ".-+,/_="
10
+
11
+ # The expansion form, plus the three escapes, ordered so a lone `%` only
12
+ # matches once nothing legal does — exactly when it's a syntax error.
13
+ EXPANSION = /%\{([^}]*)\}|%%|%_|%-|%/
14
+
15
+ # macro-letter transformers *delimiter
16
+ BODY = /\A([a-z])(\d*)([r]?)([#{Regexp.escape(DELIMITERS)}]*)\z/i
17
+
18
+ # RFC 3986's unreserved set. An uppercase macro letter expands the same as
19
+ # its lowercase twin and then URL-escapes everything outside this.
20
+ RESERVED = /[^A-Za-z0-9\-._~]/
21
+
22
+ # toplabel = ( *alphanum ALPHA *alphanum ) / ( 1*alphanum "-" *( alphanum / "-" ) alphanum )
23
+ TOPLABEL = /(?:[a-z0-9]*[a-z][a-z0-9]*|[a-z0-9]+-[a-z0-9\-]*[a-z0-9])/i
24
+
25
+ # The tail of a domain-spec must be a real label rather than something a
26
+ # macro happened to produce, or a macro expansion standing alone.
27
+ DOMAIN_END = /\.#{TOPLABEL}\.?\z/o
28
+
29
+ class << self
30
+ # Raises PermError on anything the ABNF doesn't allow: a record that
31
+ # can't be parsed can't be obeyed.
32
+ def expand(string, context)
33
+ string.to_s.gsub(EXPANSION) do |match|
34
+ case match
35
+ when "%%" then "%"
36
+ when "%_" then " "
37
+ when "%-" then "%20"
38
+ when "%" then raise PermError, "stray % in #{string}"
39
+ else expansion(Regexp.last_match(1), context)
40
+ end
41
+ end
42
+ end
43
+
44
+ # Checked before expansion, not after — a macro may expand into
45
+ # something no resolver will answer for, but that fails the
46
+ # mechanism, not the record.
47
+ def domain_spec(string, context)
48
+ if string.to_s.empty?
49
+ raise PermError, "empty domain-spec"
50
+ elsif !ends_well?(string)
51
+ raise PermError, "domain-spec #{string} does not end in a macro or a top-level label"
52
+ else
53
+ expand(string, context).downcase.chomp(".")
54
+ end
55
+ end
56
+
57
+ private
58
+ def ends_well?(string)
59
+ literal_tail = string.sub(/\A.*(?:%\{[^}]*\}|%%|%_|%-)/m, "")
60
+ literal_tail.empty? || literal_tail.match?(DOMAIN_END)
61
+ end
62
+
63
+ def expansion(body, context)
64
+ letter, digits, reverse, delimiters = body.match(BODY)&.captures
65
+
66
+ if letter
67
+ value = transform(value_for(letter.downcase, context), digits, !reverse.empty?, delimiters)
68
+ letter == letter.upcase ? escape(value) : value
69
+ else
70
+ raise PermError, "invalid macro %{#{body}}"
71
+ end
72
+ end
73
+
74
+ def value_for(letter, context)
75
+ case letter
76
+ when "s" then context.sender
77
+ when "l" then context.local
78
+ when "o" then context.sender_domain
79
+ when "d" then context.domain
80
+ when "i" then dotted(context.ip)
81
+ when "v" then context.ip.ipv4? ? "in-addr" : "ip6"
82
+ when "h" then context.helo.to_s
83
+ # %{p} needs the PTR lookup and forward confirmation of §5.5, which
84
+ # §7.2 discourages anyone from relying on and we don't do. "unknown"
85
+ # is what §7.2 prescribes when no name validates, and it matches
86
+ # nothing — where a real domain here would let `exists:%{p}.…` hit
87
+ # by accident.
88
+ when "p" then "unknown"
89
+ # c, r and t are legal in an explanation string and nowhere else,
90
+ # and we don't produce explanations.
91
+ else raise PermError, "macro letter %{#{letter}} is not allowed here"
92
+ end
93
+ end
94
+
95
+ # Dotted-quad for IPv4; one hex nibble per label for IPv6.
96
+ def dotted(ip)
97
+ if ip.ipv4?
98
+ ip.to_s
99
+ else
100
+ ip.to_string.delete(":").chars.join(".")
101
+ end
102
+ end
103
+
104
+ def transform(value, digits, reverse, delimiters)
105
+ if digits.empty? && !reverse && (delimiters.empty? || delimiters == ".")
106
+ value
107
+ else
108
+ parts = value.split(/[#{Regexp.escape(delimiters.empty? ? "." : delimiters)}]/)
109
+ parts = parts.reverse if reverse
110
+ parts = parts.last(digits.to_i) if digits.to_i > 0
111
+ parts.join(".")
112
+ end
113
+ end
114
+
115
+ def escape(value)
116
+ value.gsub(RESERVED) { |char| char.bytes.map { |byte| format("%%%02X", byte) }.join }
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,188 @@
1
+ module MailAuth
2
+ module Spf
3
+ # `target` is already macro-expanded at parse time, so a broken macro is
4
+ # caught even when the mechanism it sits in is never reached.
5
+ Term = Data.define(:qualifier, :mechanism, :target, :network, :cidr4, :cidr6) do
6
+ def initialize(qualifier:, mechanism:, target: nil, network: nil, cidr4: nil, cidr6: nil)
7
+ super
8
+ end
9
+ end
10
+
11
+ # A `v=spf1` record, read and taken apart. Parsing is complete before
12
+ # evaluation begins, so `v=spf1 -all ip6` is a broken record — not a fail
13
+ # — even though nothing after `-all` can ever be evaluated.
14
+ class Record
15
+ VERSION = "v=spf1"
16
+
17
+ # A modifier is a name and an "=" reached before any ":" or "/".
18
+ # `redirect:example.com` therefore isn't one — it's a mechanism named
19
+ # "redirect", which is to say a typo. The name stops at the first "=":
20
+ # every one after it belongs to the macro-string, where "=" is both a
21
+ # legal delimiter and a legal literal.
22
+ MODIFIER = /\A(?<name>[^:\/=]*)=(?<value>.*)\z/
23
+ MODIFIER_NAME = /\A[a-z][a-z0-9\-_.]*\z/i
24
+
25
+ MECHANISM = /\A(?<qualifier>[+\-~?])?(?<name>[a-z0-9_.\-]+)(?<argument>[:\/].*)?\z/i
26
+
27
+ # domain-spec followed by an optional dual-cidr-length. The domain is
28
+ # matched lazily so that a trailing "/24" is read as a prefix length,
29
+ # while the slashes a domain-spec is allowed to contain stay in the name.
30
+ ARGUMENT = /\A(?<spec>.*?)(?:\/(?<cidr4>\d+))?(?:\/\/(?<cidr6>\d+))?\z/
31
+ DUAL_CIDR = /\A(?:\/(?<cidr4>\d+))?(?:\/\/(?<cidr6>\d+))?\z/
32
+ ADDRESS = /\A(?<address>.*?)(?:\/(?<length>\d+))?\z/
33
+
34
+ # SPF is defined over 7-bit ASCII, and a record carrying anything else has
35
+ # been mangled somewhere between its author and us.
36
+ PRINTABLE = /\A[\x20-\x7e]*\z/
37
+
38
+ # Zero is not an error but an answer — the domain publishes no policy.
39
+ # Two is: nothing chooses between them.
40
+ def self.fetch(domain, budget:, context:)
41
+ records = budget.txt(domain).select { |record| record.strip.split(/\s+/).first&.downcase == VERSION }
42
+
43
+ case records.length
44
+ when 0 then raise NoRecord, "no SPF record for #{domain}"
45
+ when 1 then new(records.first, context)
46
+ else raise PermError, "#{records.length} SPF records for #{domain}"
47
+ end
48
+ end
49
+
50
+ attr_reader :terms, :redirect
51
+
52
+ def initialize(text, context)
53
+ @text = text
54
+ @context = context
55
+ @terms = []
56
+ @modifiers = {}
57
+
58
+ parse
59
+ end
60
+
61
+ private
62
+ def parse
63
+ raise PermError, "non-ASCII characters in record" unless @text.match?(PRINTABLE)
64
+
65
+ @text.strip.split(/\s+/).drop(1).each do |term|
66
+ if modifier = term.match(MODIFIER)
67
+ parse_modifier(modifier[:name], modifier[:value])
68
+ else
69
+ @terms << parse_mechanism(term)
70
+ end
71
+ end
72
+
73
+ @redirect = @modifiers["redirect"]
74
+ end
75
+
76
+ def parse_modifier(name, value)
77
+ raise PermError, "invalid modifier name #{name}" unless name.match?(MODIFIER_NAME)
78
+
79
+ case name.downcase
80
+ when "redirect", "exp"
81
+ if @modifiers.key?(name.downcase)
82
+ raise PermError, "#{name} given more than once"
83
+ else
84
+ @modifiers[name.downcase] = Macro.domain_spec(value, @context)
85
+ end
86
+ else
87
+ # An unknown modifier does nothing, but its value is still a
88
+ # macro-string, and a record that can't state it correctly is
89
+ # broken however little we care about the answer.
90
+ Macro.expand(value, @context)
91
+ end
92
+ end
93
+
94
+ def parse_mechanism(term)
95
+ mechanism = term.match(MECHANISM)
96
+ raise PermError, "invalid term #{term}" unless mechanism
97
+
98
+ qualifier = mechanism[:qualifier] || "+"
99
+ argument = mechanism[:argument]
100
+
101
+ case name = mechanism[:name].downcase
102
+ when "all" then all_term(qualifier, argument)
103
+ when "include", "exists" then named_term(qualifier, name.to_sym, argument)
104
+ when "ptr" then ptr_term(qualifier, argument)
105
+ when "a", "mx" then address_term(qualifier, name.to_sym, argument)
106
+ when "ip4", "ip6" then ip_term(qualifier, name.to_sym, argument)
107
+ else raise PermError, "unknown mechanism #{name}"
108
+ end
109
+ end
110
+
111
+ def all_term(qualifier, argument)
112
+ if argument
113
+ raise PermError, "all takes no argument"
114
+ else
115
+ Term.new(qualifier: qualifier, mechanism: :all)
116
+ end
117
+ end
118
+
119
+ def named_term(qualifier, mechanism, argument)
120
+ if argument&.start_with?(":")
121
+ Term.new(qualifier: qualifier, mechanism: mechanism, target: Macro.domain_spec(argument[1..], @context))
122
+ else
123
+ raise PermError, "#{mechanism} needs a domain"
124
+ end
125
+ end
126
+
127
+ # A bare `ptr` asks about the domain the record was found at.
128
+ def ptr_term(qualifier, argument)
129
+ if argument.nil?
130
+ Term.new(qualifier: qualifier, mechanism: :ptr, target: @context.domain)
131
+ elsif argument.start_with?(":")
132
+ Term.new(qualifier: qualifier, mechanism: :ptr, target: Macro.domain_spec(argument[1..], @context))
133
+ else
134
+ raise PermError, "ptr takes no prefix length"
135
+ end
136
+ end
137
+
138
+ def address_term(qualifier, mechanism, argument)
139
+ if argument.nil?
140
+ Term.new(qualifier: qualifier, mechanism: mechanism, target: @context.domain)
141
+ elsif argument.start_with?(":")
142
+ parsed = argument[1..].match(ARGUMENT)
143
+
144
+ Term.new(qualifier: qualifier, mechanism: mechanism, target: Macro.domain_spec(parsed[:spec], @context),
145
+ cidr4: prefix(parsed[:cidr4], 32), cidr6: prefix(parsed[:cidr6], 128))
146
+ else
147
+ parsed = argument.match(DUAL_CIDR) or raise PermError, "invalid prefix length #{argument}"
148
+
149
+ Term.new(qualifier: qualifier, mechanism: mechanism, target: @context.domain,
150
+ cidr4: prefix(parsed[:cidr4], 32), cidr6: prefix(parsed[:cidr6], 128))
151
+ end
152
+ end
153
+
154
+ def ip_term(qualifier, mechanism, argument)
155
+ raise PermError, "#{mechanism} needs an address" unless argument&.start_with?(":")
156
+
157
+ parsed = argument[1..].match(ADDRESS)
158
+ bits = mechanism == :ip4 ? 32 : 128
159
+ # We have taken the prefix length off ourselves; IPAddr would happily
160
+ # read a second one, and `ip4:1.2.3.4//32` is not a subtlety, it's a
161
+ # mistake.
162
+ raise PermError, "#{parsed[:address]} is not an address" if parsed[:address].include?("/")
163
+
164
+ network = IPAddr.new(parsed[:address])
165
+ raise PermError, "#{parsed[:address]} is not an #{mechanism} address" unless network.family == family(mechanism)
166
+
167
+ Term.new(qualifier: qualifier, mechanism: mechanism, network: network.mask(prefix(parsed[:length], bits) || bits))
168
+ rescue IPAddr::Error
169
+ raise PermError, "#{parsed[:address]} is not an address"
170
+ end
171
+
172
+ def family(mechanism)
173
+ mechanism == :ip4 ? Socket::AF_INET : Socket::AF_INET6
174
+ end
175
+
176
+ # Leading zeros and prefix lengths past the end of the address are
177
+ # both rejected.
178
+ def prefix(digits, limit)
179
+ if digits
180
+ raise PermError, "prefix length /#{digits} has a leading zero" if digits.length > 1 && digits.start_with?("0")
181
+ raise PermError, "prefix length /#{digits} is out of range" if digits.to_i > limit
182
+
183
+ digits.to_i
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end