dmarcator 0.11.0 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 689cd037f395f7803f4157fd148305f1d9d6c3dbb19fd42b61311b8e37d57124
4
- data.tar.gz: 80518a23a140cb7102cb4d20a5fa896852ca8b104a3047939bcf4ba018e89699
3
+ metadata.gz: 6f88c01ff39e839792769a66063bd78cd312f51d5fba6917acf6777d034e0269
4
+ data.tar.gz: 9b39f980958757f2a27718d157ad9925424a20da358a7100d3b87f9a3765af5f
5
5
  SHA512:
6
- metadata.gz: c3b5f59702e05d0ffeeec17c9c3e7f8c1bd078448ba029fd4cf6bc8e6cbd8107dbda2e3633c228d1706f09832023a8a64b9806d6ee843b1fe13d449b19379e0c
7
- data.tar.gz: de98c5111a0eb8023fa10807c7227d18bead12e17f2f43d084d8c5e3d27a2b1a4e86212c0c075fa2e75a97e5710e738de2baad45b833c31249d54a1abec80bb2
6
+ metadata.gz: 4c5aa75a034fedffc21ad9100de83b82f049dc08416f66cdcf2056ec6f76a8f28415ccfee012ea6432d4cbbbd760279c5a0dbce32d614f0932a0d8e9cd9efa3e
7
+ data.tar.gz: c1351578b215094ca74bb710725f2b66ad49f8e1c24ba14862ee32bc11099163f0d0e8cbb0736f5d23a614b6d1b801ea4fa9cd52e0a3f521f13ffffa21959897
data/ChangeLog.md CHANGED
@@ -1,3 +1,36 @@
1
+ ### 1.0.0 / 2026-10-30
2
+
3
+ The public API is now frozen.
4
+
5
+ * The contract spec added in 0.12.0 defines the supported surface: the module methods, constants and per-class public methods it asserts. Anything documented as `@api private` may still change in a minor release.
6
+ * Deprecations are documentation-only, and removals happen only in a major release. `Report.max_report_bytes`, `Report.max_report_bytes=` and `Report::MAX_REPORT_BYTES` are deprecated in favour of `DMARCator.configure` and `Config::DEFAULT_MAX_REPORT_BYTES`, and will be removed in 2.0.
7
+ * Supported Ruby is `>= 3.3.13`. Dropping a Ruby series is the one documented exception to the no-breaking-changes promise: it is announced in a minor release and removed in the next major.
8
+ * Parsed report values are now frozen, not merely reader-only: the strings taken from the document and the arrays holding records, errors, reasons, auth results and the parsed record's `rua`/`ruf`/`fo` cannot be mutated in place. `Record` and the report value objects are therefore safe to share between threads.
9
+ * The parser and XML helper moved to `DMARCator::Internal`, so the public constants are only documented API and the parser can be replaced in a minor release.
10
+ * Parsing, lookup and transport behaviour are unchanged from 0.12.0; the differences above are the freeze itself.
11
+
12
+ ### 0.12.0 / 2026-10-29
13
+
14
+ Breaking changes, taken before 1.0:
15
+
16
+ * `Report#version` returns the published value as a String instead of a `BigDecimal`, and the `bigdecimal` runtime dependency is gone. A non-numeric version still yields `nil`.
17
+ * Parsed report objects (`Report::Metadata`, `Policy`, `Row`, `AuthResult`, `Reason`) are read-only. Their attributes were previously writable, which allowed a parsed report to be mutated into a state that never came from the document.
18
+ * `Report.max_report_bytes` now delegates to `DMARCator.config.max_report_bytes`. The class-level accessors remain, so existing callers keep working; `Config#max_report_bytes` defaults to 25 MiB and rejects values that are not a positive Integer or nil.
19
+
20
+ Other changes:
21
+
22
+ * `DMARCator::XmlHelper` methods are private to including classes, `Record.truncate_error_message` is private, and `DMARCator::Parser` is documented as `@api private`.
23
+ * Add an API-contract spec that pins the public surface of every public class, so additions and removals are deliberate.
24
+ * Add `SECURITY.md` and document the stability, thread-safety and deprecation policies in the README.
25
+
26
+ Migration:
27
+
28
+ | Before | After |
29
+ |---|---|
30
+ | `report.version == BigDecimal("0.1")` | `report.version == "0.1"` |
31
+ | `report.policy.pct = 50` | assign nothing; parsed values are read-only |
32
+ | `Report.max_report_bytes = n` | `DMARCator.configure { |c| c.max_report_bytes = n }` |
33
+
1
34
  ### 0.11.0 / 2026-10-29
2
35
 
3
36
  * Add `DMARCator.configure` for process-wide defaults, so a resolver can be set from code instead of only through the `DMARCATOR_DNS` environment variables. A configured resolver takes precedence over the environment, and `DMARCator.reset_configuration!` discards it.
data/README.md CHANGED
@@ -201,6 +201,7 @@ Process-wide defaults can be set from code, and take precedence over the
201
201
  ```ruby
202
202
  DMARCator.configure do |config|
203
203
  config.resolver = DMARCator::Resolver::DoH.new(url: "https://dns.example/dns-query")
204
+ config.max_report_bytes = 5 * 1024 * 1024
204
205
  end
205
206
  ```
206
207
 
@@ -236,7 +237,7 @@ Parse a DMARC aggregate/forensic report:
236
237
  report = DMARCator::Report.new(xml_string)
237
238
 
238
239
  report.version
239
- # => 0.1e0
240
+ # => "0.1"
240
241
 
241
242
  report.metadata.org_name
242
243
  # => "google.com"
@@ -287,18 +288,51 @@ resolver = DMARCator::Resolver::DoH.new(url: "https://dns.example/dns-query")
287
288
  record = DMARCator::Record.lookup("example.com", resolver: resolver)
288
289
  ```
289
290
 
291
+ ## Stability
292
+
293
+ The public API is pinned by a contract spec in the test suite: the module
294
+ methods, constants and per-class public methods it asserts are the supported
295
+ surface. Anything marked `@api private` in the documentation — the parser, the
296
+ XML helper, the internal resolver plumbing — may change in a minor release.
297
+
298
+ ### Thread safety
299
+
300
+ * `DMARCator::Record`, `DMARCator::Report` and the parsed value objects are
301
+ read-only after construction and safe to share between threads.
302
+ * The memoized parser and transform are shared, and safe for concurrent use.
303
+ * Resolvers are not necessarily safe for concurrent lookups: `Resolv::DNS` does
304
+ not serialise queries. Use one resolver per thread, or configure a factory
305
+ with `DMARCator.configure` so each lookup builds its own.
306
+ * `DMARCator.configure` mutates process-wide state. Call it during boot rather
307
+ than per request.
308
+
309
+ ### Deprecations
310
+
311
+ A method is deprecated in a minor release by documentation only — no runtime
312
+ warnings, so nothing is added to application logs. Deprecated methods are
313
+ removed in the next major release.
314
+
315
+ ### Ruby support
316
+
317
+ The minimum is Ruby 3.3.13, the first release in that series with the regular
318
+ expression fixes the library requires. Dropping support for a Ruby series is
319
+ the one documented exception to the no-breaking-changes promise: it is announced
320
+ in a minor release and removed in the next major.
321
+
290
322
  ## Security
291
323
 
292
- DMARC records and XML reports are treated as untrusted input:
324
+ DMARC records and XML reports are treated as untrusted input. See
325
+ [SECURITY.md](https://github.com/bermannoah/dmarcator/blob/main/SECURITY.md)
326
+ for the reporting policy and scope.
293
327
 
294
328
  * XML reports are parsed with network access disabled (`NONET`), so external
295
329
  entities are never resolved or fetched.
296
- * Reports larger than `DMARCator::Report.max_report_bytes` (25 MiB by
330
+ * Reports larger than `DMARCator.config.max_report_bytes` (25 MiB by
297
331
  default) are rejected before parsing. Parsing builds a DOM roughly 13× the
298
332
  input size, so lower the limit to bound memory:
299
333
 
300
334
  ```ruby
301
- DMARCator::Report.max_report_bytes = 5 * 1024 * 1024
335
+ DMARCator.configure { |config| config.max_report_bytes = 5 * 1024 * 1024 }
302
336
  ```
303
337
  * A warning is emitted at load time if the bundled `resolv` gem is affected by
304
338
  CVE-2026-80212/CVE-2026-80213.
@@ -316,7 +350,6 @@ DMARC records and XML reports are treated as untrusted input:
316
350
  * [Ruby] >= 3.3.13
317
351
  * [parslet] >= 1.0.0, < 3.0.0
318
352
  * [nokogiri] >= 1.10
319
- * [bigdecimal] >= 1.0
320
353
 
321
354
  ## Testing
322
355
 
@@ -386,5 +419,4 @@ created by D Salko and released under the MIT License.
386
419
  [Ruby]: https://www.ruby-lang.org
387
420
  [parslet]: https://kschiess.github.io/parslet/
388
421
  [nokogiri]: https://nokogiri.org
389
- [bigdecimal]: https://github.com/ruby/bigdecimal
390
422
  [LICENSE.txt]: LICENSE.txt
@@ -7,6 +7,15 @@ module DMARCator
7
7
  # @since 0.11.0
8
8
  #
9
9
  class Config
10
+ # Default maximum accepted report size in bytes (25 MiB).
11
+ #
12
+ # Parsing a report builds a DOM roughly 13x the input size, so this bounds
13
+ # the memory a hostile report can cost.
14
+ #
15
+ # @since 0.12.0
16
+ #
17
+ DEFAULT_MAX_REPORT_BYTES = 25 * 1024 * 1024
18
+
10
19
  #
11
20
  # The default resolver used when a lookup is not given one.
12
21
  #
@@ -18,6 +27,36 @@ module DMARCator
18
27
  # @return [Object, #call, nil]
19
28
  #
20
29
  attr_accessor :resolver
30
+
31
+ #
32
+ # The maximum accepted report size in bytes.
33
+ #
34
+ # Defaults to {DEFAULT_MAX_REPORT_BYTES}. Lower it to bound memory for
35
+ # reports from untrusted sources.
36
+ #
37
+ # @return [Integer]
38
+ #
39
+ def max_report_bytes
40
+ @max_report_bytes || DEFAULT_MAX_REPORT_BYTES
41
+ end
42
+
43
+ #
44
+ # Sets the maximum accepted report size in bytes.
45
+ #
46
+ # @param [Integer, nil] value
47
+ # A positive number of bytes, or `nil` to restore the default.
48
+ #
49
+ # @raise [ArgumentError]
50
+ # The value is not a positive Integer or nil.
51
+ #
52
+ def max_report_bytes=(value)
53
+ unless value.nil? || (value.is_a?(Integer) && value.positive?)
54
+ raise ArgumentError,
55
+ "max_report_bytes must be a positive Integer or nil, got #{value.inspect}"
56
+ end
57
+
58
+ @max_report_bytes = value
59
+ end
21
60
  end
22
61
 
23
62
  #
@@ -0,0 +1,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../exceptions'
4
+ require_relative '../uri'
5
+
6
+ require 'parslet'
7
+ require 'uri'
8
+
9
+ module DMARCator
10
+ #
11
+ # Implementation details of the library. Nothing here is covered by the
12
+ # 1.0 compatibility promise.
13
+ #
14
+ # @api private
15
+ #
16
+ module Internal
17
+ #
18
+ # DMARC parser.
19
+ #
20
+ # An implementation detail: use {Record.parse} rather than this class.
21
+ #
22
+ # @api private
23
+ #
24
+ # @see https://tools.ietf.org/html/rfc7489#section-6.4
25
+ #
26
+ class Parser < Parslet::Parser
27
+
28
+ # Maximum accepted DMARC record size in bytes. Real records are far
29
+ # smaller; the cap bounds parsing work on hostile input.
30
+ #
31
+ MAX_RECORD_BYTES = 4096
32
+
33
+ root :dmarc_record
34
+
35
+ rule(:dmarc_record) do
36
+ dmarc_version.repeat(1,1) >>
37
+ (dmarc_sep >> dmarc_tag).repeat >>
38
+ dmarc_sep.maybe >>
39
+ wsp?.maybe
40
+ end
41
+
42
+ rule(:dmarc_sep) { wsp? >> str(';') >> wsp? }
43
+
44
+ rule(:dmarc_version) do
45
+ str('v') >> wsp? >>
46
+ str('=') >> wsp? >>
47
+ str('DMARC1').as(:v)
48
+ end
49
+
50
+ rule(:dmarc_tag) do
51
+ dmarc_request |
52
+ dmarc_srequest |
53
+ dmarc_auri |
54
+ dmarc_furi |
55
+ dmarc_adkim |
56
+ dmarc_aspf |
57
+ dmarc_ainterval |
58
+ dmarc_fo |
59
+ dmarc_rfmt |
60
+ dmarc_percent |
61
+ unknown_tag
62
+ end
63
+
64
+ #
65
+ # Defines a rule for a `tag=value` DMARC tag, capturing values that do not
66
+ # match the rule as `:invalid_tag` so strict mode can reject them.
67
+ #
68
+ # @param [Symbol] name
69
+ # The rule name, used as `dmarc_<name>`.
70
+ #
71
+ # @param [String] tag
72
+ # The DMARC tag name.
73
+ #
74
+ # @yieldreturn [Parslet::Atoms::Base]
75
+ # The parslet matching the tag's value.
76
+ #
77
+ # @api private
78
+ #
79
+ def self.tag_rule(name,tag,&block)
80
+ rule(:"dmarc_#{name}") do
81
+ (
82
+ str(tag) >> wsp? >> str('=') >> wsp? >>
83
+ instance_eval(&block).as(tag.to_sym)
84
+ ) |
85
+ (
86
+ str(tag) >> wsp? >> str('=') >> wsp? >>
87
+ match["^; \t"].repeat(1)
88
+ ).as(:invalid_tag)
89
+ end
90
+ end
91
+
92
+ tag_rule(:request,'p') do
93
+ str('none') | str('quarantine') | str('reject')
94
+ end
95
+
96
+ tag_rule(:srequest,'sp') do
97
+ str('none') | str('quarantine') | str('reject')
98
+ end
99
+
100
+ tag_rule(:auri, 'rua') do
101
+ dmarc_uri >> (uri_list_sep.present? >> uri_list_sep).repeat >> (wsp? >> str(',')).absent?
102
+ end
103
+
104
+ tag_rule(:ainterval,'ri') { digit.repeat(1, 10) >> digit.absent? }
105
+
106
+ tag_rule(:furi,'ruf') do
107
+ dmarc_uri >> (uri_list_sep.present? >> uri_list_sep).repeat >> (wsp? >> str(',')).absent?
108
+ end
109
+
110
+ tag_rule(:fo,'fo') do
111
+ fo_opt >> (wsp? >> str(':') >> wsp? >> fo_opt).repeat
112
+ end
113
+
114
+ rule(:fo_opt) { match['01ds'].as(:fo_opt) }
115
+
116
+ tag_rule(:rfmt,'rf') { str('afrf') | str('iodef') }
117
+
118
+ tag_rule(:percent,'pct') { digit.repeat(1,3) >> digit.absent? }
119
+
120
+ tag_rule(:adkim, 'adkim') { match['rs'] }
121
+ tag_rule(:aspf, 'aspf') { match['rs'] }
122
+
123
+ rule(:unknown_tag) { match["^; \t"].repeat(1).as(:unknown_tag) }
124
+
125
+ rule(:dmarc_uri) do
126
+ mailto_uri.as(:uri) >> (
127
+ str('!') >> digit.repeat(1, 10).as(:size) >> digit.absent? >> (
128
+ match['kmgt']
129
+ ).as(:unit).maybe
130
+ ).maybe >> str('!').absent?
131
+ end
132
+
133
+ rule(:uri_list_sep) { wsp? >> str(',') >> wsp? >> dmarc_uri }
134
+
135
+ rule(:mailto_uri) do
136
+ match['mM'] >> match['aA'] >> match['iI'] >> match['lL'] >> match['tT'] >> match['oO'] >>
137
+ str(':') >> mailto_address
138
+ end
139
+
140
+ rule(:mailto_address) do
141
+ mailto_local >> str('@') >> mailto_domain
142
+ end
143
+
144
+ rule(:mailto_local) { mailto_char.repeat(1) }
145
+ rule(:mailto_domain) { mailto_char.repeat(1) }
146
+ rule(:mailto_char) { match["A-Za-z0-9._%+-"] }
147
+
148
+ rule(:digit) { match('[0-9]') }
149
+ rule(:wsp) { str(' ') | str("\t") }
150
+ rule(:wsp?) { wsp.repeat }
151
+
152
+ #
153
+ # Converts the raw parse tree into tag attributes.
154
+ #
155
+ # @api private
156
+ #
157
+ class Transform < Parslet::Transform
158
+
159
+ rule(fo_opt: simple(:fo_opt)) { fo_opt }
160
+ rule(fo: simple(:fo_opt)) { {fo: [fo_opt]} }
161
+
162
+ rule(v: simple(:version)) { {v: version.to_sym} }
163
+ rule(p: simple(:p)) { {p: p.to_sym } }
164
+ rule(sp: simple(:sp)) { {sp: sp.to_sym} }
165
+ rule(rf: simple(:rf)) { {rf: rf.to_sym} }
166
+ rule(adkim: simple(:adkim)) { {adkim: adkim.to_sym} }
167
+ rule(aspf: simple(:aspf)) { {aspf: aspf.to_sym} }
168
+
169
+ rule(pct: simple(:pct)) { {pct: pct.to_i} }
170
+ rule(ri: simple(:ri)) { {ri: ri.to_i} }
171
+
172
+ rule(uri: simple(:uri), size: simple(:size), unit: simple(:unit)) do
173
+ Uri.new(
174
+ Transform.parse_uri(uri),
175
+ size.to_i,
176
+ unit.to_sym
177
+ )
178
+ end
179
+ rule(uri: simple(:uri), size: simple(:size)) do
180
+ Uri.new(
181
+ Transform.parse_uri(uri),
182
+ size.to_i
183
+ )
184
+ end
185
+ rule(uri: simple(:uri)) { Uri.new(Transform.parse_uri(uri)) }
186
+ rule(rua: subtree(:uris)) { {rua: Array(uris)} }
187
+ rule(ruf: subtree(:uris)) { {ruf: Array(uris)} }
188
+
189
+ # Maximum length of error messages echoed from untrusted input.
190
+ MAX_ERROR_MESSAGE_LENGTH = 300
191
+
192
+ #
193
+ # Parses a `mailto:` URI from a parse tree slice.
194
+ #
195
+ # @param [Parslet::Slice] value
196
+ #
197
+ # @return [URI::MailTo]
198
+ #
199
+ # @raise [InvalidRecord]
200
+ # The URI is not a valid `mailto:` URI.
201
+ #
202
+ # @api private
203
+ #
204
+ def self.parse_uri(value)
205
+ uri = value.to_s
206
+
207
+ begin
208
+ parsed = URI.parse(uri)
209
+ rescue URI::Error => error
210
+ raise InvalidRecord, truncate(error.message)
211
+ end
212
+
213
+ unless parsed.is_a?(URI::MailTo)
214
+ raise InvalidRecord, "invalid DMARC report URI (expected mailto): #{truncate(uri)}"
215
+ end
216
+
217
+ parsed
218
+ end
219
+
220
+ #
221
+ # Truncates an error message to a bounded length.
222
+ #
223
+ # @param [String] message
224
+ #
225
+ # @return [String]
226
+ #
227
+ # @api private
228
+ #
229
+ def self.truncate(message)
230
+ message = message.to_s
231
+
232
+ if message.length > MAX_ERROR_MESSAGE_LENGTH
233
+ message[0, MAX_ERROR_MESSAGE_LENGTH] + "..."
234
+ else
235
+ message
236
+ end
237
+ end
238
+
239
+ end
240
+
241
+ #
242
+ # Parses a DMARC record.
243
+ #
244
+ # @param [String] record
245
+ # The raw DMARC record to parse.
246
+ #
247
+ # @param [Boolean] strict (false)
248
+ # When `true`, raise on unknown tags or invalid tag values instead of
249
+ # silently ignoring them.
250
+ #
251
+ # @return [Hash{Symbol => Object}]
252
+ # The Hash of tags within the record.
253
+ #
254
+ # @raise [InvalidRecord]
255
+ # The record is not a String, is malformed, is too large, or (in strict
256
+ # mode) contains an unknown tag or invalid tag value.
257
+ #
258
+ def self.parse(record, strict: false)
259
+ unless record.is_a?(String)
260
+ raise InvalidRecord, "DMARC record must be a String, got #{record.class}"
261
+ end
262
+
263
+ if record.bytesize > MAX_RECORD_BYTES
264
+ raise InvalidRecord, "DMARC record exceeds maximum size of #{MAX_RECORD_BYTES} bytes"
265
+ end
266
+
267
+ tree = parser.parse(record)
268
+
269
+ if strict
270
+ tree.each do |node|
271
+ next unless ignored?(node)
272
+
273
+ if node.key?(:unknown_tag)
274
+ raise InvalidRecord, "unknown DMARC tag: #{Transform.truncate(node[:unknown_tag].to_s).inspect}"
275
+ else
276
+ raise InvalidRecord, "invalid DMARC tag value: #{Transform.truncate(node[:invalid_tag].to_s).inspect}"
277
+ end
278
+ end
279
+ end
280
+
281
+ tags = transform.apply(tree.reject { |node| ignored?(node) })
282
+ hash = {}
283
+
284
+ tags.each do |tag|
285
+ tag.each_key do |key|
286
+ if hash.key?(key)
287
+ raise InvalidRecord, "duplicate DMARC tag: #{key}"
288
+ end
289
+ end
290
+
291
+ hash.merge!(tag)
292
+ end
293
+
294
+ validate!(hash)
295
+
296
+ return hash
297
+ end
298
+
299
+ #
300
+ # Determines whether a parse-tree node represents a tag that the lenient
301
+ # parser dropped because it was unknown or had an invalid value.
302
+ #
303
+ # @param [Object] node
304
+ # The parse-tree node.
305
+ #
306
+ # @return [Boolean]
307
+ #
308
+ def self.ignored?(node)
309
+ node.is_a?(Hash) && (node.key?(:unknown_tag) || node.key?(:invalid_tag))
310
+ end
311
+
312
+ #
313
+ # The memoized parser instance. Parslet caches rules per instance, so
314
+ # reusing it avoids rebuilding the grammar on every call.
315
+ #
316
+ # @return [Parser]
317
+ #
318
+ # @api private
319
+ #
320
+ def self.parser
321
+ @parser ||= new
322
+ end
323
+
324
+ #
325
+ # The memoized transform instance.
326
+ #
327
+ # @return [Transform]
328
+ #
329
+ # @api private
330
+ #
331
+ def self.transform
332
+ @transform ||= Transform.new
333
+ end
334
+
335
+ #
336
+ # Validates the parsed tags.
337
+ #
338
+ # @param [Hash{Symbol => Object}] tags
339
+ # The parsed tags.
340
+ #
341
+ # @raise [InvalidRecord]
342
+ # One of the tags has an invalid value.
343
+ #
344
+ def self.validate!(tags)
345
+ if tags[:pct] && !(tags[:pct].is_a?(Integer) && tags[:pct].between?(0, 100))
346
+ raise InvalidRecord, "pct must be an integer between 0 and 100"
347
+ end
348
+
349
+ if tags[:ri] && !(tags[:ri].is_a?(Integer) && tags[:ri].between?(0, 2**32 - 1))
350
+ raise InvalidRecord, "ri must be an integer between 0 and 4294967295"
351
+ end
352
+ end
353
+
354
+ end
355
+ end
356
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ module DMARCator
6
+ #
7
+ # Implementation details of the library. Nothing here is covered by the
8
+ # 1.0 compatibility promise.
9
+ #
10
+ # @api private
11
+ #
12
+ module Internal
13
+ #
14
+ # Internal helper for extracting values from parsed DMARC XML reports.
15
+ #
16
+ module XmlHelper
17
+ private
18
+
19
+ #
20
+ # Returns the text of the first node matching the XPath expression.
21
+ #
22
+ # @param [String] xpath
23
+ # The XPath expression to match.
24
+ #
25
+ # @return [String, nil]
26
+ # The node text, or `nil` if no node matched.
27
+ #
28
+ def get_text(xpath)
29
+ @node.at_xpath(xpath)&.text
30
+ end
31
+
32
+ #
33
+ # Parses a Unix timestamp from raw element text.
34
+ #
35
+ # @param [String, nil] value
36
+ # The element text.
37
+ #
38
+ # @return [Time, nil]
39
+ #
40
+ # @api private
41
+ #
42
+ def parse_timestamp(value)
43
+ seconds = parse_integer(value)
44
+
45
+ Time.at(seconds) if seconds
46
+ end
47
+
48
+ #
49
+ # Parses an integer from raw element text, returning `nil` for values that
50
+ # are not plain decimal digits.
51
+ #
52
+ # @param [String, nil] value
53
+ # The element text.
54
+ #
55
+ # @return [Integer, nil]
56
+ #
57
+ # @api private
58
+ #
59
+ def parse_integer(value)
60
+ return nil if value.nil? || value.empty?
61
+
62
+ digits = value.strip
63
+ return nil unless digits.match?(/\A\d+\z/)
64
+
65
+ digits.to_i
66
+ end
67
+ end
68
+ end
69
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'dmarcator/query'
4
- require 'dmarcator/parser'
4
+ require 'dmarcator/internal/parser'
5
5
  require 'dmarcator/exceptions'
6
6
 
7
7
  require 'resolv'
@@ -66,13 +66,13 @@ module DMARCator
66
66
  @v = attributes.fetch(:v)
67
67
  @adkim = attributes[:adkim]
68
68
  @aspf = attributes[:aspf]
69
- @fo = attributes[:fo]
69
+ @fo = attributes[:fo]&.freeze
70
70
  @p = attributes[:p]
71
71
  @pct = attributes[:pct]
72
72
  @rf = attributes[:rf]
73
73
  @ri = attributes[:ri]
74
- @rua = attributes[:rua]
75
- @ruf = attributes[:ruf]
74
+ @rua = attributes[:rua]&.freeze
75
+ @ruf = attributes[:ruf]&.freeze
76
76
  @sp = attributes[:sp]
77
77
  end
78
78
 
@@ -277,7 +277,7 @@ module DMARCator
277
277
  # @api public
278
278
  #
279
279
  def self.parse(record, strict: false)
280
- attributes = Parser.parse(record, strict: strict)
280
+ attributes = Internal::Parser.parse(record, strict: strict)
281
281
 
282
282
  unless attributes[:p]
283
283
  raise InvalidRecord, "DMARC record is missing the required 'p' tag"
@@ -312,6 +312,8 @@ module DMARCator
312
312
  end
313
313
  end
314
314
 
315
+ private_class_method :truncate_error_message
316
+
315
317
  #
316
318
  # Looks up and parses the DMARC record for a domain.
317
319
  #