@blamejs/blamejs-shop 0.5.12 → 0.5.13

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.
Files changed (521) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/lib/asset-manifest.json +1 -1
  3. package/lib/vendor/MANIFEST.json +632 -416
  4. package/lib/vendor/blamejs/.clusterfuzzlite/Dockerfile +21 -10
  5. package/lib/vendor/blamejs/.clusterfuzzlite/build.sh +30 -9
  6. package/lib/vendor/blamejs/.github/dependabot.yml +8 -0
  7. package/lib/vendor/blamejs/.github/workflows/actions-lint.yml +3 -3
  8. package/lib/vendor/blamejs/.github/workflows/cflite_batch.yml +7 -2
  9. package/lib/vendor/blamejs/.github/workflows/cflite_pr.yml +7 -2
  10. package/lib/vendor/blamejs/.github/workflows/ci.yml +21 -21
  11. package/lib/vendor/blamejs/.github/workflows/codeql.yml +3 -3
  12. package/lib/vendor/blamejs/.github/workflows/npm-publish.yml +4 -4
  13. package/lib/vendor/blamejs/.github/workflows/release-container.yml +7 -7
  14. package/lib/vendor/blamejs/.github/workflows/scorecard.yml +3 -3
  15. package/lib/vendor/blamejs/.github/workflows/sha-to-tag-verify.yml +1 -1
  16. package/lib/vendor/blamejs/.gitignore +2 -0
  17. package/lib/vendor/blamejs/CHANGELOG.md +122 -0
  18. package/lib/vendor/blamejs/NOTICE +26 -1
  19. package/lib/vendor/blamejs/SECURITY.md +1 -1
  20. package/lib/vendor/blamejs/api-snapshot.json +207 -5
  21. package/lib/vendor/blamejs/docker/keycloak/realm-blamejs-test.json +29 -0
  22. package/lib/vendor/blamejs/examples/wiki/DEPLOY.md +1 -0
  23. package/lib/vendor/blamejs/examples/wiki/lib/build-app.js +31 -0
  24. package/lib/vendor/blamejs/examples/wiki/lib/source-comment-block-validator.js +109 -0
  25. package/lib/vendor/blamejs/examples/wiki/nginx.conf +106 -0
  26. package/lib/vendor/blamejs/examples/wiki/package-lock.json +1 -1
  27. package/lib/vendor/blamejs/examples/wiki/test/e2e.js +92 -0
  28. package/lib/vendor/blamejs/fuzz/asn1-der.fuzz.js +28 -0
  29. package/lib/vendor/blamejs/fuzz/cms-codec.fuzz.js +17 -0
  30. package/lib/vendor/blamejs/fuzz/link-header.fuzz.js +18 -0
  31. package/lib/vendor/blamejs/index.js +6 -0
  32. package/lib/vendor/blamejs/lib/a2a.js +4 -4
  33. package/lib/vendor/blamejs/lib/agent-idempotency.js +18 -2
  34. package/lib/vendor/blamejs/lib/agent-saga.js +36 -8
  35. package/lib/vendor/blamejs/lib/agent-snapshot.js +63 -6
  36. package/lib/vendor/blamejs/lib/app-shutdown.js +23 -20
  37. package/lib/vendor/blamejs/lib/archive-read.js +8 -0
  38. package/lib/vendor/blamejs/lib/archive-tar.js +12 -2
  39. package/lib/vendor/blamejs/lib/arg-parser.js +49 -5
  40. package/lib/vendor/blamejs/lib/asn1-der.js +12 -0
  41. package/lib/vendor/blamejs/lib/audit-sign.js +47 -1
  42. package/lib/vendor/blamejs/lib/audit.js +71 -8
  43. package/lib/vendor/blamejs/lib/auth/ciba.js +14 -2
  44. package/lib/vendor/blamejs/lib/auth/fido-mds3.js +58 -47
  45. package/lib/vendor/blamejs/lib/auth/jwt.js +34 -0
  46. package/lib/vendor/blamejs/lib/auth/oauth.js +90 -13
  47. package/lib/vendor/blamejs/lib/auth/oid4vci.js +20 -17
  48. package/lib/vendor/blamejs/lib/auth/openid-federation.js +214 -23
  49. package/lib/vendor/blamejs/lib/auth/saml.js +38 -20
  50. package/lib/vendor/blamejs/lib/auth/sd-jwt-vc.js +10 -4
  51. package/lib/vendor/blamejs/lib/backup/bundle.js +13 -3
  52. package/lib/vendor/blamejs/lib/backup/index.js +16 -5
  53. package/lib/vendor/blamejs/lib/backup/manifest.js +18 -5
  54. package/lib/vendor/blamejs/lib/base32.js +21 -0
  55. package/lib/vendor/blamejs/lib/break-glass.js +19 -10
  56. package/lib/vendor/blamejs/lib/bundler.js +5 -1
  57. package/lib/vendor/blamejs/lib/cache-status.js +3 -7
  58. package/lib/vendor/blamejs/lib/calendar.js +21 -1
  59. package/lib/vendor/blamejs/lib/cbor.js +12 -1
  60. package/lib/vendor/blamejs/lib/cert.js +29 -11
  61. package/lib/vendor/blamejs/lib/cli.js +94 -24
  62. package/lib/vendor/blamejs/lib/cloud-events.js +2 -2
  63. package/lib/vendor/blamejs/lib/cluster-provider-db.js +13 -3
  64. package/lib/vendor/blamejs/lib/codepoint-class.js +85 -0
  65. package/lib/vendor/blamejs/lib/compliance-ai-act-prohibited.js +8 -4
  66. package/lib/vendor/blamejs/lib/compliance-ai-act-transparency.js +19 -4
  67. package/lib/vendor/blamejs/lib/compliance-ai-act.js +6 -8
  68. package/lib/vendor/blamejs/lib/content-credentials.js +1 -1
  69. package/lib/vendor/blamejs/lib/content-digest.js +2 -1
  70. package/lib/vendor/blamejs/lib/cookies.js +13 -3
  71. package/lib/vendor/blamejs/lib/cose.js +31 -16
  72. package/lib/vendor/blamejs/lib/crypto-field.js +32 -0
  73. package/lib/vendor/blamejs/lib/crypto-oprf.js +11 -2
  74. package/lib/vendor/blamejs/lib/crypto-xwing.js +22 -4
  75. package/lib/vendor/blamejs/lib/crypto.js +314 -19
  76. package/lib/vendor/blamejs/lib/csp.js +11 -3
  77. package/lib/vendor/blamejs/lib/daemon.js +270 -24
  78. package/lib/vendor/blamejs/lib/dark-patterns.js +5 -2
  79. package/lib/vendor/blamejs/lib/db-declare-view.js +2 -2
  80. package/lib/vendor/blamejs/lib/db-query.js +34 -7
  81. package/lib/vendor/blamejs/lib/db.js +18 -6
  82. package/lib/vendor/blamejs/lib/dbsc.js +17 -0
  83. package/lib/vendor/blamejs/lib/dev.js +21 -2
  84. package/lib/vendor/blamejs/lib/dsr.js +157 -102
  85. package/lib/vendor/blamejs/lib/external-db.js +41 -2
  86. package/lib/vendor/blamejs/lib/file-upload.js +25 -7
  87. package/lib/vendor/blamejs/lib/flag-providers.js +27 -15
  88. package/lib/vendor/blamejs/lib/forms.js +113 -27
  89. package/lib/vendor/blamejs/lib/gate-contract.js +23 -10
  90. package/lib/vendor/blamejs/lib/gdpr-ropa.js +8 -1
  91. package/lib/vendor/blamejs/lib/guard-agent-registry.js +1 -1
  92. package/lib/vendor/blamejs/lib/guard-archive.js +2 -2
  93. package/lib/vendor/blamejs/lib/guard-cidr.js +27 -1
  94. package/lib/vendor/blamejs/lib/guard-graphql.js +30 -11
  95. package/lib/vendor/blamejs/lib/guard-html.js +26 -53
  96. package/lib/vendor/blamejs/lib/guard-image.js +24 -5
  97. package/lib/vendor/blamejs/lib/guard-imap-command.js +10 -2
  98. package/lib/vendor/blamejs/lib/guard-jmap.js +19 -3
  99. package/lib/vendor/blamejs/lib/guard-jwt.js +18 -2
  100. package/lib/vendor/blamejs/lib/guard-list-unsubscribe.js +2 -2
  101. package/lib/vendor/blamejs/lib/guard-managesieve-command.js +10 -2
  102. package/lib/vendor/blamejs/lib/guard-markdown.js +11 -24
  103. package/lib/vendor/blamejs/lib/guard-pdf.js +18 -2
  104. package/lib/vendor/blamejs/lib/guard-pop3-command.js +10 -2
  105. package/lib/vendor/blamejs/lib/guard-regex.js +8 -5
  106. package/lib/vendor/blamejs/lib/guard-svg.js +31 -32
  107. package/lib/vendor/blamejs/lib/guard-time.js +1 -1
  108. package/lib/vendor/blamejs/lib/guard-uuid.js +2 -2
  109. package/lib/vendor/blamejs/lib/html-balance.js +3 -3
  110. package/lib/vendor/blamejs/lib/http-client-cache.js +82 -4
  111. package/lib/vendor/blamejs/lib/http-client-cookie-jar.js +16 -1
  112. package/lib/vendor/blamejs/lib/http-client.js +39 -7
  113. package/lib/vendor/blamejs/lib/http-message-signature.js +35 -1
  114. package/lib/vendor/blamejs/lib/i18n-messageformat.js +31 -6
  115. package/lib/vendor/blamejs/lib/i18n.js +35 -21
  116. package/lib/vendor/blamejs/lib/incident-report.js +8 -1
  117. package/lib/vendor/blamejs/lib/ip-utils.js +11 -1
  118. package/lib/vendor/blamejs/lib/json-path.js +23 -1
  119. package/lib/vendor/blamejs/lib/jwk.js +20 -3
  120. package/lib/vendor/blamejs/lib/link-header.js +2 -1
  121. package/lib/vendor/blamejs/lib/log-stream-otlp-grpc.js +5 -1
  122. package/lib/vendor/blamejs/lib/log-stream-otlp.js +5 -1
  123. package/lib/vendor/blamejs/lib/log-stream.js +8 -3
  124. package/lib/vendor/blamejs/lib/mail-auth.js +123 -22
  125. package/lib/vendor/blamejs/lib/mail-bimi.js +1 -1
  126. package/lib/vendor/blamejs/lib/mail-bounce.js +8 -0
  127. package/lib/vendor/blamejs/lib/mail-crypto-pgp.js +46 -10
  128. package/lib/vendor/blamejs/lib/mail-dav.js +22 -3
  129. package/lib/vendor/blamejs/lib/mail-dkim.js +96 -30
  130. package/lib/vendor/blamejs/lib/mail-helo.js +6 -1
  131. package/lib/vendor/blamejs/lib/mail-scan.js +23 -16
  132. package/lib/vendor/blamejs/lib/mail-send-deliver.js +34 -8
  133. package/lib/vendor/blamejs/lib/mail-server-imap.js +3 -4
  134. package/lib/vendor/blamejs/lib/mail-server-managesieve.js +7 -11
  135. package/lib/vendor/blamejs/lib/mail-server-mx.js +93 -7
  136. package/lib/vendor/blamejs/lib/mail-server-registry.js +8 -2
  137. package/lib/vendor/blamejs/lib/mail.js +15 -5
  138. package/lib/vendor/blamejs/lib/metrics.js +268 -63
  139. package/lib/vendor/blamejs/lib/middleware/age-gate.js +7 -1
  140. package/lib/vendor/blamejs/lib/middleware/asyncapi-serve.js +1 -1
  141. package/lib/vendor/blamejs/lib/middleware/body-parser.js +19 -4
  142. package/lib/vendor/blamejs/lib/middleware/bot-guard.js +5 -9
  143. package/lib/vendor/blamejs/lib/middleware/csp-report.js +40 -8
  144. package/lib/vendor/blamejs/lib/middleware/csrf-protect.js +24 -10
  145. package/lib/vendor/blamejs/lib/middleware/dpop.js +10 -5
  146. package/lib/vendor/blamejs/lib/middleware/index.js +6 -1
  147. package/lib/vendor/blamejs/lib/middleware/nel.js +1 -1
  148. package/lib/vendor/blamejs/lib/middleware/openapi-serve.js +1 -1
  149. package/lib/vendor/blamejs/lib/middleware/rate-limit.js +12 -1
  150. package/lib/vendor/blamejs/lib/middleware/require-bound-key.js +1 -1
  151. package/lib/vendor/blamejs/lib/middleware/scim-server.js +1 -1
  152. package/lib/vendor/blamejs/lib/middleware/tus-upload.js +6 -2
  153. package/lib/vendor/blamejs/lib/mtls-ca.js +18 -2
  154. package/lib/vendor/blamejs/lib/network-dns-resolver.js +11 -1
  155. package/lib/vendor/blamejs/lib/network-dns.js +21 -2
  156. package/lib/vendor/blamejs/lib/network-tls.js +69 -7
  157. package/lib/vendor/blamejs/lib/nonce-store.js +9 -1
  158. package/lib/vendor/blamejs/lib/object-store/gcs.js +7 -0
  159. package/lib/vendor/blamejs/lib/object-store/sigv4.js +31 -0
  160. package/lib/vendor/blamejs/lib/observability-otlp-exporter.js +1 -1
  161. package/lib/vendor/blamejs/lib/outbox.js +1 -1
  162. package/lib/vendor/blamejs/lib/parsers/safe-ini.js +9 -1
  163. package/lib/vendor/blamejs/lib/parsers/safe-toml.js +10 -1
  164. package/lib/vendor/blamejs/lib/permissions.js +6 -3
  165. package/lib/vendor/blamejs/lib/pid-probe.js +55 -0
  166. package/lib/vendor/blamejs/lib/pqc-agent.js +8 -1
  167. package/lib/vendor/blamejs/lib/pqc-software.js +3 -3
  168. package/lib/vendor/blamejs/lib/pubsub.js +20 -9
  169. package/lib/vendor/blamejs/lib/queue-local.js +8 -1
  170. package/lib/vendor/blamejs/lib/queue-redis.js +5 -0
  171. package/lib/vendor/blamejs/lib/redact.js +54 -0
  172. package/lib/vendor/blamejs/lib/restore-bundle.js +10 -2
  173. package/lib/vendor/blamejs/lib/restore.js +19 -0
  174. package/lib/vendor/blamejs/lib/retention.js +12 -4
  175. package/lib/vendor/blamejs/lib/router.js +60 -6
  176. package/lib/vendor/blamejs/lib/safe-buffer.js +69 -0
  177. package/lib/vendor/blamejs/lib/safe-decompress.js +8 -2
  178. package/lib/vendor/blamejs/lib/safe-dns.js +25 -20
  179. package/lib/vendor/blamejs/lib/safe-ical.js +7 -3
  180. package/lib/vendor/blamejs/lib/safe-mime.js +12 -1
  181. package/lib/vendor/blamejs/lib/safe-object.js +80 -0
  182. package/lib/vendor/blamejs/lib/safe-path.js +52 -4
  183. package/lib/vendor/blamejs/lib/safe-redirect.js +10 -2
  184. package/lib/vendor/blamejs/lib/safe-schema.js +4 -1
  185. package/lib/vendor/blamejs/lib/safe-sieve.js +33 -1
  186. package/lib/vendor/blamejs/lib/safe-sql.js +88 -0
  187. package/lib/vendor/blamejs/lib/safe-vcard.js +7 -3
  188. package/lib/vendor/blamejs/lib/scheduler.js +8 -1
  189. package/lib/vendor/blamejs/lib/security-assert.js +6 -6
  190. package/lib/vendor/blamejs/lib/self-update-standalone-verifier.js +74 -27
  191. package/lib/vendor/blamejs/lib/self-update.js +517 -87
  192. package/lib/vendor/blamejs/lib/server-timing.js +2 -4
  193. package/lib/vendor/blamejs/lib/session.js +34 -12
  194. package/lib/vendor/blamejs/lib/sql.js +15 -1
  195. package/lib/vendor/blamejs/lib/ssrf-guard.js +52 -0
  196. package/lib/vendor/blamejs/lib/static.js +55 -13
  197. package/lib/vendor/blamejs/lib/storage.js +8 -2
  198. package/lib/vendor/blamejs/lib/subject.js +6 -1
  199. package/lib/vendor/blamejs/lib/tenant-quota.js +52 -13
  200. package/lib/vendor/blamejs/lib/tls-exporter.js +3 -3
  201. package/lib/vendor/blamejs/lib/tsa.js +34 -13
  202. package/lib/vendor/blamejs/lib/uri-template.js +8 -1
  203. package/lib/vendor/blamejs/lib/vault/index.js +13 -0
  204. package/lib/vendor/blamejs/lib/vc.js +44 -5
  205. package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +26 -26
  206. package/lib/vendor/blamejs/lib/vendor/noble-ciphers.cjs +792 -1
  207. package/lib/vendor/blamejs/lib/vendor/noble-curves.cjs +3443 -1
  208. package/lib/vendor/blamejs/lib/vendor/noble-post-quantum.cjs +2737 -1
  209. package/lib/vendor/blamejs/lib/vendor/pki.cjs +39550 -13
  210. package/lib/vendor/blamejs/lib/vendor/public-suffix-list.dat +55 -53
  211. package/lib/vendor/blamejs/lib/vendor/public-suffix-list.data.js +5551 -5554
  212. package/lib/vendor/blamejs/lib/vendor/simplewebauthn-server.cjs +22459 -32
  213. package/lib/vendor/blamejs/lib/watcher.js +89 -17
  214. package/lib/vendor/blamejs/lib/webhook-dispatcher.js +25 -3
  215. package/lib/vendor/blamejs/lib/webhook.js +12 -2
  216. package/lib/vendor/blamejs/lib/ws-client.js +32 -1
  217. package/lib/vendor/blamejs/lib/xml-c14n.js +29 -1
  218. package/lib/vendor/blamejs/oss-fuzz/projects/blamejs/Dockerfile +1 -1
  219. package/lib/vendor/blamejs/package-lock.json +2 -2
  220. package/lib/vendor/blamejs/package.json +1 -1
  221. package/lib/vendor/blamejs/release-notes/v0.16.x.json +1462 -0
  222. package/lib/vendor/blamejs/release-notes/v0.17.0.json +31 -0
  223. package/lib/vendor/blamejs/release-notes/v0.17.1.json +31 -0
  224. package/lib/vendor/blamejs/release-notes/v0.17.10.json +30 -0
  225. package/lib/vendor/blamejs/release-notes/v0.17.11.json +22 -0
  226. package/lib/vendor/blamejs/release-notes/v0.17.12.json +18 -0
  227. package/lib/vendor/blamejs/release-notes/v0.17.13.json +77 -0
  228. package/lib/vendor/blamejs/release-notes/v0.17.14.json +40 -0
  229. package/lib/vendor/blamejs/release-notes/v0.17.15.json +18 -0
  230. package/lib/vendor/blamejs/release-notes/v0.17.16.json +27 -0
  231. package/lib/vendor/blamejs/release-notes/v0.17.17.json +31 -0
  232. package/lib/vendor/blamejs/release-notes/v0.17.18.json +18 -0
  233. package/lib/vendor/blamejs/release-notes/v0.17.19.json +18 -0
  234. package/lib/vendor/blamejs/release-notes/v0.17.2.json +26 -0
  235. package/lib/vendor/blamejs/release-notes/v0.17.20.json +43 -0
  236. package/lib/vendor/blamejs/release-notes/v0.17.21.json +26 -0
  237. package/lib/vendor/blamejs/release-notes/v0.17.22.json +18 -0
  238. package/lib/vendor/blamejs/release-notes/v0.17.3.json +43 -0
  239. package/lib/vendor/blamejs/release-notes/v0.17.4.json +35 -0
  240. package/lib/vendor/blamejs/release-notes/v0.17.5.json +26 -0
  241. package/lib/vendor/blamejs/release-notes/v0.17.6.json +26 -0
  242. package/lib/vendor/blamejs/release-notes/v0.17.7.json +35 -0
  243. package/lib/vendor/blamejs/release-notes/v0.17.8.json +22 -0
  244. package/lib/vendor/blamejs/release-notes/v0.17.9.json +26 -0
  245. package/lib/vendor/blamejs/scripts/validate-source-comment-blocks.js +4 -0
  246. package/lib/vendor/blamejs/scripts/vendor-update.sh +293 -9
  247. package/lib/vendor/blamejs/socket.yml +94 -0
  248. package/lib/vendor/blamejs/test/00-primitives.js +605 -0
  249. package/lib/vendor/blamejs/test/helpers/drivers.js +12 -9
  250. package/lib/vendor/blamejs/test/integration/audit-stack-mysql.test.js +90 -1
  251. package/lib/vendor/blamejs/test/integration/audit-stack-postgres.test.js +122 -1
  252. package/lib/vendor/blamejs/test/integration/data-layer-mysql.test.js +30 -0
  253. package/lib/vendor/blamejs/test/integration/data-layer-pg.test.js +29 -0
  254. package/lib/vendor/blamejs/test/integration/dsr-cluster-mysql.test.js +479 -0
  255. package/lib/vendor/blamejs/test/integration/dsr-cluster-pg.test.js +487 -0
  256. package/lib/vendor/blamejs/test/integration/federation-auth.test.js +313 -5
  257. package/lib/vendor/blamejs/test/integration/mail-dkim.test.js +80 -0
  258. package/lib/vendor/blamejs/test/integration/openid-federation-chain.test.js +275 -0
  259. package/lib/vendor/blamejs/test/integration/webhook-dispatcher-pg.test.js +96 -0
  260. package/lib/vendor/blamejs/test/layer-0-primitives/a2a-tasks.test.js +401 -0
  261. package/lib/vendor/blamejs/test/layer-0-primitives/acme.test.js +1649 -2
  262. package/lib/vendor/blamejs/test/layer-0-primitives/age-gate.test.js +33 -0
  263. package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +0 -0
  264. package/lib/vendor/blamejs/test/layer-0-primitives/agent-saga.test.js +60 -0
  265. package/lib/vendor/blamejs/test/layer-0-primitives/agent-snapshot.test.js +524 -0
  266. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +891 -0
  267. package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +128 -1
  268. package/lib/vendor/blamejs/test/layer-0-primitives/archive-adapters.test.js +271 -0
  269. package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +628 -1
  270. package/lib/vendor/blamejs/test/layer-0-primitives/archive-tar-read.test.js +0 -0
  271. package/lib/vendor/blamejs/test/layer-0-primitives/archive-wrap.test.js +227 -0
  272. package/lib/vendor/blamejs/test/layer-0-primitives/arg-parser.test.js +15 -0
  273. package/lib/vendor/blamejs/test/layer-0-primitives/asn1-der.test.js +73 -0
  274. package/lib/vendor/blamejs/test/layer-0-primitives/atomic-file.test.js +114 -0
  275. package/lib/vendor/blamejs/test/layer-0-primitives/audit-sign-anchor.test.js +141 -0
  276. package/lib/vendor/blamejs/test/layer-0-primitives/audit-tools.test.js +624 -0
  277. package/lib/vendor/blamejs/test/layer-0-primitives/audit.test.js +769 -0
  278. package/lib/vendor/blamejs/test/layer-0-primitives/auth-jwt-defenses.test.js +436 -0
  279. package/lib/vendor/blamejs/test/layer-0-primitives/auth-oauth.test.js +1697 -0
  280. package/lib/vendor/blamejs/test/layer-0-primitives/auth-oid4vp.test.js +495 -0
  281. package/lib/vendor/blamejs/test/layer-0-primitives/auth-password.test.js +500 -0
  282. package/lib/vendor/blamejs/test/layer-0-primitives/auth-saml.test.js +1809 -0
  283. package/lib/vendor/blamejs/test/layer-0-primitives/auth-status-list.test.js +243 -1
  284. package/lib/vendor/blamejs/test/layer-0-primitives/backup-index.test.js +2226 -0
  285. package/lib/vendor/blamejs/test/layer-0-primitives/backup-manifest-signature.test.js +183 -0
  286. package/lib/vendor/blamejs/test/layer-0-primitives/base32.test.js +105 -0
  287. package/lib/vendor/blamejs/test/layer-0-primitives/break-glass.test.js +693 -0
  288. package/lib/vendor/blamejs/test/layer-0-primitives/cache.test.js +603 -0
  289. package/lib/vendor/blamejs/test/layer-0-primitives/calendar.test.js +557 -0
  290. package/lib/vendor/blamejs/test/layer-0-primitives/case-insensitive-security-token-guard.test.js +214 -0
  291. package/lib/vendor/blamejs/test/layer-0-primitives/cbor.test.js +25 -0
  292. package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +843 -6
  293. package/lib/vendor/blamejs/test/layer-0-primitives/ciba-authreqid-binding.test.js +77 -2
  294. package/lib/vendor/blamejs/test/layer-0-primitives/ciba.test.js +866 -0
  295. package/lib/vendor/blamejs/test/layer-0-primitives/clear-site-data.test.js +18 -0
  296. package/lib/vendor/blamejs/test/layer-0-primitives/cli-erase.test.js +19 -0
  297. package/lib/vendor/blamejs/test/layer-0-primitives/cli.test.js +2790 -0
  298. package/lib/vendor/blamejs/test/layer-0-primitives/cluster-lease-renewal-ttl.test.js +114 -0
  299. package/lib/vendor/blamejs/test/layer-0-primitives/cluster.test.js +1032 -0
  300. package/lib/vendor/blamejs/test/layer-0-primitives/cms-codec.test.js +575 -0
  301. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +739 -1
  302. package/lib/vendor/blamejs/test/layer-0-primitives/codepoint-class.test.js +8 -0
  303. package/lib/vendor/blamejs/test/layer-0-primitives/compliance-ai-act.test.js +194 -0
  304. package/lib/vendor/blamejs/test/layer-0-primitives/compliance-lifecycle-proto-key.test.js +104 -0
  305. package/lib/vendor/blamejs/test/layer-0-primitives/compliance-sanctions.test.js +254 -0
  306. package/lib/vendor/blamejs/test/layer-0-primitives/content-credentials.test.js +613 -3
  307. package/lib/vendor/blamejs/test/layer-0-primitives/cookies.test.js +140 -0
  308. package/lib/vendor/blamejs/test/layer-0-primitives/cose.test.js +407 -0
  309. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-adversarial.test.js +352 -0
  310. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field-derived-hash.test.js +32 -0
  311. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field.test.js +575 -0
  312. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-hash-files-parallel.test.js +10 -0
  313. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-hash-stream.test.js +24 -0
  314. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-oprf.test.js +17 -0
  315. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-spki-pin.test.js +183 -0
  316. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-xwing.test.js +48 -0
  317. package/lib/vendor/blamejs/test/layer-0-primitives/crypto.test.js +345 -0
  318. package/lib/vendor/blamejs/test/layer-0-primitives/csp-report.test.js +58 -0
  319. package/lib/vendor/blamejs/test/layer-0-primitives/csv.test.js +48 -0
  320. package/lib/vendor/blamejs/test/layer-0-primitives/daemon.test.js +459 -1
  321. package/lib/vendor/blamejs/test/layer-0-primitives/dark-patterns.test.js +19 -0
  322. package/lib/vendor/blamejs/test/layer-0-primitives/db-query.test.js +1019 -0
  323. package/lib/vendor/blamejs/test/layer-0-primitives/db-raw-residency-gate.test.js +77 -0
  324. package/lib/vendor/blamejs/test/layer-0-primitives/db.test.js +1621 -0
  325. package/lib/vendor/blamejs/test/layer-0-primitives/ddl-change-control.test.js +462 -3
  326. package/lib/vendor/blamejs/test/layer-0-primitives/dnssec.test.js +323 -0
  327. package/lib/vendor/blamejs/test/layer-0-primitives/dpop-middleware-verify.test.js +562 -0
  328. package/lib/vendor/blamejs/test/layer-0-primitives/dsr.test.js +901 -1
  329. package/lib/vendor/blamejs/test/layer-0-primitives/dual-control.test.js +249 -0
  330. package/lib/vendor/blamejs/test/layer-0-primitives/external-db-migrate.test.js +561 -0
  331. package/lib/vendor/blamejs/test/layer-0-primitives/external-db.test.js +1214 -0
  332. package/lib/vendor/blamejs/test/layer-0-primitives/fal.test.js +21 -0
  333. package/lib/vendor/blamejs/test/layer-0-primitives/fedcm-dbsc.test.js +46 -20
  334. package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +147 -3
  335. package/lib/vendor/blamejs/test/layer-0-primitives/fido-mds3.test.js +907 -38
  336. package/lib/vendor/blamejs/test/layer-0-primitives/file-upload-content-safety-skip-audit.test.js +29 -0
  337. package/lib/vendor/blamejs/test/layer-0-primitives/file-upload.test.js +1135 -0
  338. package/lib/vendor/blamejs/test/layer-0-primitives/flag.test.js +30 -0
  339. package/lib/vendor/blamejs/test/layer-0-primitives/forms.test.js +747 -0
  340. package/lib/vendor/blamejs/test/layer-0-primitives/gate-contract.test.js +1855 -0
  341. package/lib/vendor/blamejs/test/layer-0-primitives/gdpr-ropa.test.js +30 -0
  342. package/lib/vendor/blamejs/test/layer-0-primitives/guard-archive.test.js +86 -0
  343. package/lib/vendor/blamejs/test/layer-0-primitives/guard-auth.test.js +117 -0
  344. package/lib/vendor/blamejs/test/layer-0-primitives/guard-cidr.test.js +151 -0
  345. package/lib/vendor/blamejs/test/layer-0-primitives/guard-domain.test.js +422 -0
  346. package/lib/vendor/blamejs/test/layer-0-primitives/guard-filename.test.js +424 -0
  347. package/lib/vendor/blamejs/test/layer-0-primitives/{guard-gate-disposition-coverage.test.js → guard-gate-disposition.test.js} +1 -1
  348. package/lib/vendor/blamejs/test/layer-0-primitives/guard-graphql.test.js +340 -0
  349. package/lib/vendor/blamejs/test/layer-0-primitives/guard-html.test.js +29 -0
  350. package/lib/vendor/blamejs/test/layer-0-primitives/guard-idempotency-key.test.js +28 -0
  351. package/lib/vendor/blamejs/test/layer-0-primitives/guard-image.test.js +519 -45
  352. package/lib/vendor/blamejs/test/layer-0-primitives/guard-imap-command.test.js +0 -0
  353. package/lib/vendor/blamejs/test/layer-0-primitives/guard-jmap.test.js +51 -0
  354. package/lib/vendor/blamejs/test/layer-0-primitives/guard-jsonpath.test.js +66 -0
  355. package/lib/vendor/blamejs/test/layer-0-primitives/guard-jwt.test.js +436 -0
  356. package/lib/vendor/blamejs/test/layer-0-primitives/guard-managesieve-command.test.js +25 -0
  357. package/lib/vendor/blamejs/test/layer-0-primitives/guard-markdown.test.js +16 -0
  358. package/lib/vendor/blamejs/test/layer-0-primitives/guard-mime.test.js +86 -0
  359. package/lib/vendor/blamejs/test/layer-0-primitives/guard-oauth.test.js +140 -0
  360. package/lib/vendor/blamejs/test/layer-0-primitives/guard-pdf.test.js +80 -0
  361. package/lib/vendor/blamejs/test/layer-0-primitives/guard-pop3-command.test.js +27 -0
  362. package/lib/vendor/blamejs/test/layer-0-primitives/guard-regex.test.js +99 -0
  363. package/lib/vendor/blamejs/test/layer-0-primitives/guard-shell.test.js +76 -0
  364. package/lib/vendor/blamejs/test/layer-0-primitives/{guard-sql-coverage.test.js → guard-sql.test.js} +3 -3
  365. package/lib/vendor/blamejs/test/layer-0-primitives/guard-svg.test.js +524 -0
  366. package/lib/vendor/blamejs/test/layer-0-primitives/guard-template.test.js +71 -0
  367. package/lib/vendor/blamejs/test/layer-0-primitives/guard-time.test.js +92 -0
  368. package/lib/vendor/blamejs/test/layer-0-primitives/guard-uuid.test.js +86 -0
  369. package/lib/vendor/blamejs/test/layer-0-primitives/html-balance.test.js +38 -0
  370. package/lib/vendor/blamejs/test/layer-0-primitives/http-client-cache-authorization.test.js +347 -0
  371. package/lib/vendor/blamejs/test/layer-0-primitives/http-client-cache.test.js +908 -0
  372. package/lib/vendor/blamejs/test/layer-0-primitives/http-client.test.js +2874 -0
  373. package/lib/vendor/blamejs/test/layer-0-primitives/http-message-signature.test.js +643 -0
  374. package/lib/vendor/blamejs/test/layer-0-primitives/i18n-messageformat.test.js +38 -0
  375. package/lib/vendor/blamejs/test/layer-0-primitives/i18n.test.js +1153 -1
  376. package/lib/vendor/blamejs/test/layer-0-primitives/idempotency-key.test.js +494 -0
  377. package/lib/vendor/blamejs/test/layer-0-primitives/ip-utils.test.js +30 -0
  378. package/lib/vendor/blamejs/test/layer-0-primitives/jsdoc-example-execution.test.js +177 -0
  379. package/lib/vendor/blamejs/test/layer-0-primitives/json-path.test.js +103 -0
  380. package/lib/vendor/blamejs/test/layer-0-primitives/json-schema.test.js +214 -0
  381. package/lib/vendor/blamejs/test/layer-0-primitives/jwk.test.js +41 -0
  382. package/lib/vendor/blamejs/test/layer-0-primitives/jwt-external.test.js +276 -0
  383. package/lib/vendor/blamejs/test/layer-0-primitives/keychain.test.js +0 -0
  384. package/lib/vendor/blamejs/test/layer-0-primitives/log-stream.test.js +241 -0
  385. package/lib/vendor/blamejs/test/layer-0-primitives/log.test.js +106 -0
  386. package/lib/vendor/blamejs/test/layer-0-primitives/mail-agent.test.js +441 -0
  387. package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +2136 -4
  388. package/lib/vendor/blamejs/test/layer-0-primitives/mail-bimi.test.js +759 -12
  389. package/lib/vendor/blamejs/test/layer-0-primitives/mail-bounce.test.js +811 -1
  390. package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-pgp-experimental.test.js +401 -3
  391. package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-pgp.test.js +751 -8
  392. package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +864 -0
  393. package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto.test.js +110 -0
  394. package/lib/vendor/blamejs/test/layer-0-primitives/mail-dav.test.js +830 -1
  395. package/lib/vendor/blamejs/test/layer-0-primitives/mail-deploy.test.js +635 -86
  396. package/lib/vendor/blamejs/test/layer-0-primitives/mail-dkim.test.js +818 -0
  397. package/lib/vendor/blamejs/test/layer-0-primitives/mail-helo.test.js +27 -0
  398. package/lib/vendor/blamejs/test/layer-0-primitives/mail-scan.test.js +199 -0
  399. package/lib/vendor/blamejs/test/layer-0-primitives/mail-send-deliver.test.js +686 -2
  400. package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-imap.test.js +778 -0
  401. package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-jmap.test.js +1438 -0
  402. package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-managesieve.test.js +449 -6
  403. package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-mx.test.js +773 -2
  404. package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-pop3.test.js +623 -3
  405. package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-rate-limit.test.js +55 -0
  406. package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-registry.test.js +13 -0
  407. package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-submission.test.js +877 -1
  408. package/lib/vendor/blamejs/test/layer-0-primitives/mail-sieve.test.js +326 -0
  409. package/lib/vendor/blamejs/test/layer-0-primitives/mail-store-fts.test.js +55 -0
  410. package/lib/vendor/blamejs/test/layer-0-primitives/mail-store.test.js +242 -0
  411. package/lib/vendor/blamejs/test/layer-0-primitives/mail.test.js +1596 -2
  412. package/lib/vendor/blamejs/test/layer-0-primitives/mcp.test.js +440 -4
  413. package/lib/vendor/blamejs/test/layer-0-primitives/metrics-snapshot.test.js +89 -0
  414. package/lib/vendor/blamejs/test/layer-0-primitives/metrics.test.js +1230 -0
  415. package/lib/vendor/blamejs/test/layer-0-primitives/middleware-assetlinks.test.js +113 -0
  416. package/lib/vendor/blamejs/test/layer-0-primitives/middleware-body-parser.test.js +1169 -0
  417. package/lib/vendor/blamejs/test/layer-0-primitives/middleware-bot-disclose.test.js +123 -0
  418. package/lib/vendor/blamejs/test/layer-0-primitives/middleware-cookies.test.js +108 -0
  419. package/lib/vendor/blamejs/test/layer-0-primitives/middleware-gpc.test.js +121 -0
  420. package/lib/vendor/blamejs/test/layer-0-primitives/middleware-headers.test.js +163 -0
  421. package/lib/vendor/blamejs/test/layer-0-primitives/middleware-host-allowlist.test.js +154 -0
  422. package/lib/vendor/blamejs/test/layer-0-primitives/middleware-require-content-type.test.js +127 -0
  423. package/lib/vendor/blamejs/test/layer-0-primitives/{middleware-tus-upload-coverage.test.js → middleware-tus-upload.test.js} +45 -0
  424. package/lib/vendor/blamejs/test/layer-0-primitives/middleware-web-app-manifest.test.js +132 -0
  425. package/lib/vendor/blamejs/test/layer-0-primitives/mtls-ca-crl-fingerprint-only.test.js +92 -0
  426. package/lib/vendor/blamejs/test/layer-0-primitives/network-dns-resolver.test.js +65 -0
  427. package/lib/vendor/blamejs/test/layer-0-primitives/network-dns.test.js +1727 -1
  428. package/lib/vendor/blamejs/test/layer-0-primitives/network-nts.test.js +699 -0
  429. package/lib/vendor/blamejs/test/layer-0-primitives/network-smtp-policy.test.js +1181 -0
  430. package/lib/vendor/blamejs/test/layer-0-primitives/network-tls.test.js +1876 -3
  431. package/lib/vendor/blamejs/test/layer-0-primitives/network.test.js +32 -0
  432. package/lib/vendor/blamejs/test/layer-0-primitives/nonce-store-enforce-replay-nonboolean.test.js +81 -0
  433. package/lib/vendor/blamejs/test/layer-0-primitives/ntp-check.test.js +122 -0
  434. package/lib/vendor/blamejs/test/layer-0-primitives/observability-tracing.test.js +378 -0
  435. package/lib/vendor/blamejs/test/layer-0-primitives/observability.test.js +77 -0
  436. package/lib/vendor/blamejs/test/layer-0-primitives/oid4vci.test.js +1016 -0
  437. package/lib/vendor/blamejs/test/layer-0-primitives/openid-federation.test.js +946 -0
  438. package/lib/vendor/blamejs/test/layer-0-primitives/outbox.test.js +818 -0
  439. package/lib/vendor/blamejs/test/layer-0-primitives/own-property-membership-guards.test.js +109 -0
  440. package/lib/vendor/blamejs/test/layer-0-primitives/parsers-safe-toml.test.js +543 -0
  441. package/lib/vendor/blamejs/test/layer-0-primitives/{parsers-safe-yaml-coverage.test.js → parsers-safe-yaml.test.js} +2 -2
  442. package/lib/vendor/blamejs/test/layer-0-primitives/parsers-standalone.test.js +73 -0
  443. package/lib/vendor/blamejs/test/layer-0-primitives/passkey.test.js +649 -0
  444. package/lib/vendor/blamejs/test/layer-0-primitives/permissions.test.js +524 -0
  445. package/lib/vendor/blamejs/test/layer-0-primitives/pqc-agent-curve.test.js +22 -0
  446. package/lib/vendor/blamejs/test/layer-0-primitives/pubsub.test.js +295 -0
  447. package/lib/vendor/blamejs/test/layer-0-primitives/queue-flow-repeat.test.js +36 -0
  448. package/lib/vendor/blamejs/test/layer-0-primitives/queue.test.js +83 -0
  449. package/lib/vendor/blamejs/test/layer-0-primitives/rate-limit-xff-spoofing.test.js +34 -0
  450. package/lib/vendor/blamejs/test/layer-0-primitives/redact.test.js +1031 -0
  451. package/lib/vendor/blamejs/test/layer-0-primitives/request-helpers.test.js +94 -0
  452. package/lib/vendor/blamejs/test/layer-0-primitives/require-bound-key.test.js +401 -0
  453. package/lib/vendor/blamejs/test/layer-0-primitives/restore-empty-manifest-wipe.test.js +154 -0
  454. package/lib/vendor/blamejs/test/layer-0-primitives/retention.test.js +600 -0
  455. package/lib/vendor/blamejs/test/layer-0-primitives/router-host-path-injection.test.js +231 -0
  456. package/lib/vendor/blamejs/test/layer-0-primitives/router-tls0rtt.test.js +21 -0
  457. package/lib/vendor/blamejs/test/layer-0-primitives/router.test.js +1277 -0
  458. package/lib/vendor/blamejs/test/layer-0-primitives/safe-async.test.js +133 -0
  459. package/lib/vendor/blamejs/test/layer-0-primitives/safe-buffer-linear-scans.test.js +14 -0
  460. package/lib/vendor/blamejs/test/layer-0-primitives/safe-buffer.test.js +80 -0
  461. package/lib/vendor/blamejs/test/layer-0-primitives/safe-decompress.test.js +28 -0
  462. package/lib/vendor/blamejs/test/layer-0-primitives/safe-dns.test.js +245 -0
  463. package/lib/vendor/blamejs/test/layer-0-primitives/safe-ical.test.js +32 -0
  464. package/lib/vendor/blamejs/test/layer-0-primitives/safe-ini.test.js +376 -0
  465. package/lib/vendor/blamejs/test/layer-0-primitives/safe-json.test.js +175 -0
  466. package/lib/vendor/blamejs/test/layer-0-primitives/safe-mime.test.js +518 -0
  467. package/lib/vendor/blamejs/test/layer-0-primitives/safe-object.test.js +62 -0
  468. package/lib/vendor/blamejs/test/layer-0-primitives/safe-path.test.js +30 -0
  469. package/lib/vendor/blamejs/test/layer-0-primitives/safe-schema.test.js +104 -0
  470. package/lib/vendor/blamejs/test/layer-0-primitives/safe-sql.test.js +142 -0
  471. package/lib/vendor/blamejs/test/layer-0-primitives/safe-url.test.js +112 -0
  472. package/lib/vendor/blamejs/test/layer-0-primitives/safe-vcard.test.js +26 -0
  473. package/lib/vendor/blamejs/test/layer-0-primitives/scheduler.test.js +157 -0
  474. package/lib/vendor/blamejs/test/layer-0-primitives/scim-server.test.js +396 -0
  475. package/lib/vendor/blamejs/test/layer-0-primitives/sd-jwt-vc.test.js +930 -0
  476. package/lib/vendor/blamejs/test/layer-0-primitives/security-assert.test.js +755 -82
  477. package/lib/vendor/blamejs/test/layer-0-primitives/self-update.test.js +1053 -0
  478. package/lib/vendor/blamejs/test/layer-0-primitives/session-device-binding.test.js +200 -0
  479. package/lib/vendor/blamejs/test/layer-0-primitives/session-strict-binding-missing-failclosed.test.js +110 -0
  480. package/lib/vendor/blamejs/test/layer-0-primitives/sigv4-bucket-ops.test.js +636 -0
  481. package/lib/vendor/blamejs/test/layer-0-primitives/sql-offset-without-limit.test.js +94 -0
  482. package/lib/vendor/blamejs/test/layer-0-primitives/sql.test.js +1190 -0
  483. package/lib/vendor/blamejs/test/layer-0-primitives/ssrf-guard.test.js +21 -0
  484. package/lib/vendor/blamejs/test/layer-0-primitives/static.test.js +850 -0
  485. package/lib/vendor/blamejs/test/layer-0-primitives/storage-presigned-url.test.js +146 -0
  486. package/lib/vendor/blamejs/test/layer-0-primitives/storage.test.js +621 -0
  487. package/lib/vendor/blamejs/test/layer-0-primitives/subject.test.js +47 -0
  488. package/lib/vendor/blamejs/test/layer-0-primitives/template.test.js +582 -0
  489. package/lib/vendor/blamejs/test/layer-0-primitives/tenant-quota.test.js +587 -25
  490. package/lib/vendor/blamejs/test/layer-0-primitives/time.test.js +11 -0
  491. package/lib/vendor/blamejs/test/layer-0-primitives/tsa.test.js +524 -63
  492. package/lib/vendor/blamejs/test/layer-0-primitives/uri-template.test.js +27 -0
  493. package/lib/vendor/blamejs/test/layer-0-primitives/vault-rotate.test.js +866 -0
  494. package/lib/vendor/blamejs/test/layer-0-primitives/vault.test.js +74 -0
  495. package/lib/vendor/blamejs/test/layer-0-primitives/vc.test.js +215 -0
  496. package/lib/vendor/blamejs/test/layer-0-primitives/watcher.test.js +550 -0
  497. package/lib/vendor/blamejs/test/layer-0-primitives/webhook-dispatcher.test.js +388 -0
  498. package/lib/vendor/blamejs/test/layer-0-primitives/webhook.test.js +262 -0
  499. package/lib/vendor/blamejs/test/layer-0-primitives/websocket.test.js +1294 -0
  500. package/lib/vendor/blamejs/test/layer-0-primitives/ws-client.test.js +629 -0
  501. package/lib/vendor/blamejs/test/layer-0-primitives/xml-c14n-attr-normalization.test.js +164 -0
  502. package/lib/vendor/blamejs/test/smoke.js +2 -0
  503. package/package.json +1 -1
  504. package/lib/vendor/blamejs/release-notes/v0.16.0.json +0 -44
  505. package/lib/vendor/blamejs/release-notes/v0.16.1.json +0 -36
  506. package/lib/vendor/blamejs/release-notes/v0.16.2.json +0 -71
  507. package/lib/vendor/blamejs/release-notes/v0.16.3.json +0 -71
  508. package/lib/vendor/blamejs/test/layer-0-primitives/acme-coverage.test.js +0 -441
  509. package/lib/vendor/blamejs/test/layer-0-primitives/auth-oauth-coverage.test.js +0 -482
  510. package/lib/vendor/blamejs/test/layer-0-primitives/auth-saml-coverage.test.js +0 -671
  511. package/lib/vendor/blamejs/test/layer-0-primitives/backup-index-coverage.test.js +0 -690
  512. package/lib/vendor/blamejs/test/layer-0-primitives/cli-coverage.test.js +0 -238
  513. package/lib/vendor/blamejs/test/layer-0-primitives/content-credentials-coverage.test.js +0 -319
  514. package/lib/vendor/blamejs/test/layer-0-primitives/keychain-coverage.test.js +0 -0
  515. package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth-coverage.test.js +0 -437
  516. package/lib/vendor/blamejs/test/layer-0-primitives/mcp-coverage.test.js +0 -443
  517. package/lib/vendor/blamejs/test/layer-0-primitives/metrics-coverage.test.js +0 -395
  518. package/lib/vendor/blamejs/test/layer-0-primitives/network-smtp-policy-coverage.test.js +0 -565
  519. package/lib/vendor/blamejs/test/layer-0-primitives/router-coverage.test.js +0 -592
  520. package/lib/vendor/blamejs/test/layer-0-primitives/sql-coverage.test.js +0 -422
  521. package/lib/vendor/blamejs/test/layer-0-primitives/test-coverage.test.js +0 -573
@@ -8,12 +8,18 @@
8
8
  * Live DNS lookups don't run in smoke (network-bound tests live in
9
9
  * test/integration). What's covered: parse + match + alignment logic
10
10
  * via operator-supplied dnsLookup mock callbacks; ARC chain shape.
11
+ * Also: SPF qualifier / macro / CIDR / lookup-ceiling and DNS-failure
12
+ * classification edges, DMARC disposition + alignment-mode + report
13
+ * branches, ARC key-material failure arms, and the b.mail.authResults
14
+ * builder.
11
15
  */
12
16
 
13
- var helpers = require("../helpers");
14
- var b = helpers.b;
15
- var check = helpers.check;
16
- var arcSign = require("../../lib/mail-arc-sign");
17
+ var helpers = require("../helpers");
18
+ var b = helpers.b;
19
+ var check = helpers.check;
20
+ var arcSign = require("../../lib/mail-arc-sign");
21
+ var nodeCrypto = require("node:crypto");
22
+ var C = require("../../lib/constants");
17
23
 
18
24
  function testSurface() {
19
25
  check("mail.spf.verify is a function", typeof b.mail.spf.verify === "function");
@@ -1758,6 +1764,1705 @@ function testByteCapMultibyte() {
1758
1764
  r && r.ok === false && r.error && r.error.code === "mail-auth/dmarc-ruf-too-large");
1759
1765
  }
1760
1766
 
1767
+ // A TXT-only dnsLookup fake: `map[host]` returns the TXT record array
1768
+ // ([["v=spf1 ..."]]); everything else is ENOTFOUND.
1769
+ function _txtOnly(map) {
1770
+ return async function (host, type) {
1771
+ if (type === "TXT" && Object.prototype.hasOwnProperty.call(map, host)) return map[host];
1772
+ var e = new Error("ENOTFOUND"); e.code = "ENOTFOUND"; throw e;
1773
+ };
1774
+ }
1775
+
1776
+ // Resolve a promise to the error it rejects with (or null when it fulfils).
1777
+ async function _rejectedWith(promise) {
1778
+ try { await promise; return null; } catch (e) { return e; }
1779
+ }
1780
+
1781
+ function _threw(fn) {
1782
+ try { fn(); return null; } catch (e) { return e; }
1783
+ }
1784
+
1785
+ // ---- SPF: input validation (config-time throw at the entry point) ----
1786
+
1787
+ async function testSpfVerifyInputValidation() {
1788
+ var e1 = await _rejectedWith(b.mail.spf.verify({ ip: 12345, mailFrom: "a@s.example" }));
1789
+ check("spf.verify: non-string ip → spf-bad-ip throw",
1790
+ e1 && /spf-bad-ip/.test(e1.code || ""));
1791
+ var e2 = await _rejectedWith(b.mail.spf.verify({ ip: "192.0.2.5" }));
1792
+ check("spf.verify: neither mailFrom nor helo → spf-bad-domain throw",
1793
+ e2 && /spf-bad-domain/.test(e2.code || ""));
1794
+ var e3 = await _rejectedWith(b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", bogus: 1 }));
1795
+ check("spf.verify: unknown opt refused (config-time)", e3 !== null);
1796
+ }
1797
+
1798
+ // ---- SPF: `all` qualifier → verdict mapping (RFC 7208 §4.6.2) ----
1799
+
1800
+ async function testSpfAllQualifierVerdicts() {
1801
+ var cases = [
1802
+ { rec: "v=spf1 +all", want: "pass" },
1803
+ { rec: "v=spf1 ~all", want: "softfail" },
1804
+ { rec: "v=spf1 ?all", want: "neutral" },
1805
+ { rec: "v=spf1 all", want: "pass" }, // bare mechanism defaults to '+'
1806
+ ];
1807
+ for (var i = 0; i < cases.length; i += 1) {
1808
+ var dns = _txtOnly({ "s.example": [[cases[i].rec]] });
1809
+ var rv = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: dns });
1810
+ check("spf.verify(" + JSON.stringify(cases[i].rec) + ") → " + cases[i].want,
1811
+ rv.result === cases[i].want);
1812
+ }
1813
+ }
1814
+
1815
+ async function testSpfNeutralWhenNoMechanismMatches() {
1816
+ // A record with a non-matching mechanism and NO `all` / redirect falls
1817
+ // through to the "no mechanism matched" default → neutral (RFC 7208 §4.7).
1818
+ var dns = _txtOnly({ "s.example": [["v=spf1 ip4:198.51.100.0/24"]] });
1819
+ var rv = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: dns });
1820
+ check("spf.verify: no mechanism matched + no all → neutral",
1821
+ rv.result === "neutral" && /no mechanism matched/.test(rv.explanation || ""));
1822
+ }
1823
+
1824
+ // ---- SPF: ip6 mechanism (RFC 7208 §5.6) on an IPv6 connection ----
1825
+
1826
+ async function testSpfIp6Mechanism() {
1827
+ var dns = _txtOnly({ "s.example": [["v=spf1 ip6:2001:db8::/32 -all"]] });
1828
+ var hit = await b.mail.spf.verify({ ip: "2001:db8::5", mailFrom: "a@s.example", dnsLookup: dns });
1829
+ check("spf.verify(ip6:2001:db8::/32, IP in range) → pass", hit.result === "pass");
1830
+ var miss = await b.mail.spf.verify({ ip: "2001:dead::5", mailFrom: "a@s.example", dnsLookup: dns });
1831
+ check("spf.verify(ip6:2001:db8::/32, IP out of range) → fail (-all)", miss.result === "fail");
1832
+ }
1833
+
1834
+ // ---- SPF: helo fallback when MAIL FROM is absent (RFC 7208 §2.4) ----
1835
+
1836
+ async function testSpfHeloFallbackIdentity() {
1837
+ var dns = _txtOnly({ "mail.example.com": [["v=spf1 ip4:192.0.2.0/24 -all"]] });
1838
+ var rv = await b.mail.spf.verify({ ip: "192.0.2.5", helo: "mail.example.com", dnsLookup: dns });
1839
+ check("spf.verify: HELO identity used when MAIL FROM absent → pass on helo domain",
1840
+ rv.result === "pass" && rv.domain === "mail.example.com");
1841
+ }
1842
+
1843
+ // ---- SPF: include semantics (RFC 7208 §5.2) ----
1844
+
1845
+ async function testSpfIncludeSemantics() {
1846
+ // A single include: to a domain with NO SPF record MUST permerror
1847
+ // (RFC 7208 §5.2) — distinct from the void-lookup-cap path.
1848
+ var noneDns = _txtOnly({ "s.example": [["v=spf1 include:missing.example -all"]] });
1849
+ var rvNone = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: noneDns });
1850
+ check("spf.verify: include of a record-less domain → permerror (RFC 7208 §5.2)",
1851
+ rvNone.result === "permerror" && /§5\.2|has no SPF record/.test(rvNone.explanation || ""));
1852
+
1853
+ // A matching include propagates the inner pass to the outer verdict.
1854
+ var passDns = _txtOnly({
1855
+ "s.example": [["v=spf1 include:_spf.p.example -all"]],
1856
+ "_spf.p.example": [["v=spf1 ip4:192.0.2.0/24 ~all"]],
1857
+ });
1858
+ var rvPass = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: passDns });
1859
+ check("spf.verify: include that passes propagates to outer pass", rvPass.result === "pass");
1860
+ }
1861
+
1862
+ // ---- SPF: redirect= to a record-less target → permerror (RFC 7208 §6.1) ----
1863
+
1864
+ async function testSpfRedirectNoRecordPermerror() {
1865
+ var dns = _txtOnly({ "s.example": [["v=spf1 redirect=_spf.missing.example"]] });
1866
+ var rv = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: dns });
1867
+ check("spf.verify: redirect= to a record-less domain → permerror (RFC 7208 §6.1)",
1868
+ rv.result === "permerror" && /§6\.1|has no SPF record/.test(rv.explanation || ""));
1869
+ }
1870
+
1871
+ // ---- SPF: macro letters + transformers (RFC 7208 §7) via exists ----
1872
+
1873
+ async function testSpfMacroLettersAndTransformers() {
1874
+ // Each case is one `exists:` whose expanded A-query name we capture. The
1875
+ // exists RESOLVES (→ pass) so it never charges the §4.6.4 void slot — a
1876
+ // multi-miss chain would trip the void cap before every shape ran.
1877
+ async function expand(record, mailFrom) {
1878
+ var queried = [];
1879
+ var dns = async function (host, type) {
1880
+ if (type === "TXT" && host === "a.b.example") return [[record]];
1881
+ if (type === "A") { queried.push(host); return ["127.0.0.2"]; }
1882
+ var e = new Error("ENOTFOUND"); e.code = "ENOTFOUND"; throw e;
1883
+ };
1884
+ var rv = await b.mail.spf.verify({ ip: "192.0.2.9", mailFrom: mailFrom, dnsLookup: dns });
1885
+ return { result: rv.result, queried: queried };
1886
+ }
1887
+ var mf = "alice@a.b.example";
1888
+ var lo = await expand("v=spf1 exists:%{l}.%{o}.probe -all", mf);
1889
+ check("spf macro %{l}.%{o} → local-part + full sender-domain",
1890
+ lo.result === "pass" && lo.queried.indexOf("alice.a.b.example.probe") !== -1);
1891
+ var d2 = await expand("v=spf1 exists:%{d2}.probe -all", mf);
1892
+ check("spf macro %{d2} → rightmost two labels of current domain",
1893
+ d2.result === "pass" && d2.queried.indexOf("b.example.probe") !== -1);
1894
+ var dr = await expand("v=spf1 exists:%{dr}.probe -all", mf);
1895
+ check("spf macro %{dr} → reversed current-domain labels",
1896
+ dr.result === "pass" && dr.queried.indexOf("example.b.a.probe") !== -1);
1897
+ var pct = await expand("v=spf1 exists:a%%b.probe -all", mf);
1898
+ check("spf macro %% → literal percent",
1899
+ pct.result === "pass" && pct.queried.indexOf("a%b.probe") !== -1);
1900
+ var sp = await expand("v=spf1 exists:a%_b.probe -all", mf);
1901
+ check("spf macro %_ → literal space",
1902
+ sp.result === "pass" && sp.queried.indexOf("a b.probe") !== -1);
1903
+ }
1904
+
1905
+ // ---- SPF: parseRecord surfaces qualifier + modifier structure ----
1906
+
1907
+ function testSpfParseRecordStructure() {
1908
+ var rec = b.mail.spf.parseRecord("v=spf1 ~all");
1909
+ check("spf.parseRecord: bare '~all' → softfail qualifier + all mechanism",
1910
+ rec.length === 1 && rec[0].qualifier === "~" && rec[0].mechanism === "all");
1911
+ var rec2 = b.mail.spf.parseRecord("v=spf1 a:mail.example.com/24 redirect=_spf.example.com");
1912
+ check("spf.parseRecord: modifier separated from mechanisms (non-enumerable)",
1913
+ rec2.length === 1 && rec2[0].mechanism === "a" &&
1914
+ Array.isArray(rec2.modifiers) && rec2.modifiers[0].name === "redirect" &&
1915
+ rec2.modifiers[0].value === "_spf.example.com");
1916
+ }
1917
+
1918
+ // ---- DMARC: parseRecord defaults + failure classification ----
1919
+
1920
+ function testDmarcParseDefaultsAndErrors() {
1921
+ var p = b.mail.dmarc.parseRecord("v=DMARC1; p=quarantine");
1922
+ check("dmarc.parseRecord: pct/adkim/aspf default to 100/r/r when omitted",
1923
+ p.pct === 100 && p.adkim === "r" && p.aspf === "r");
1924
+ var eVer = _threw(function () { b.mail.dmarc.parseRecord("v=DMARC2; p=none"); });
1925
+ check("dmarc.parseRecord: wrong version → dmarc-bad-version",
1926
+ eVer && /dmarc-bad-version/.test(eVer.code || ""));
1927
+ var eNoP = _threw(function () { b.mail.dmarc.parseRecord("v=DMARC1; pct=100"); });
1928
+ check("dmarc.parseRecord: missing required p= → dmarc-missing-policy (RFC 7489 §6.3)",
1929
+ eNoP && /dmarc-missing-policy/.test(eNoP.code || ""));
1930
+ }
1931
+
1932
+ // ---- DMARC evaluate: from-address validation ----
1933
+
1934
+ async function testDmarcEvaluateFromValidation() {
1935
+ var e1 = await _rejectedWith(b.mail.dmarc.evaluate({ from: 123 }));
1936
+ check("dmarc.evaluate: non-string from → dmarc-bad-from",
1937
+ e1 && /dmarc-bad-from/.test(e1.code || ""));
1938
+ var e2 = await _rejectedWith(b.mail.dmarc.evaluate({ from: "nodomain" }));
1939
+ check("dmarc.evaluate: from lacking @domain → dmarc-bad-from",
1940
+ e2 && /dmarc-bad-from/.test(e2.code || ""));
1941
+ }
1942
+
1943
+ // ---- DMARC evaluate: policy-driven disposition branches ----
1944
+
1945
+ async function testDmarcEvaluatePolicyNoneDelivers() {
1946
+ // p=none is monitor-only: an unaligned message still delivers.
1947
+ var dns = _txtOnly({ "_dmarc.example.com": [["v=DMARC1; p=none"]] });
1948
+ var rv = await b.mail.dmarc.evaluate({
1949
+ from: "alice@example.com",
1950
+ spf: { result: "fail", domain: "example.com" }, dkim: [], dnsLookup: dns,
1951
+ });
1952
+ check("dmarc.evaluate: p=none unaligned → result fail but recommendedAction deliver",
1953
+ rv.result === "fail" && rv.recommendedAction === "deliver");
1954
+ }
1955
+
1956
+ async function testDmarcEvaluateNoRecordIsNone() {
1957
+ var dns = async function () { var e = new Error("ENOTFOUND"); e.code = "ENOTFOUND"; throw e; };
1958
+ var rv = await b.mail.dmarc.evaluate({
1959
+ from: "alice@norecord.example",
1960
+ spf: { result: "fail", domain: "x" }, dkim: [], dnsLookup: dns,
1961
+ });
1962
+ check("dmarc.evaluate: no _dmarc record → result none",
1963
+ rv.result === "none" && rv.policy === null);
1964
+ }
1965
+
1966
+ async function testDmarcEvaluatePermerrorOnBadRecord() {
1967
+ // A syntactically bad record (invalid p=) is a PERMANENT error, not a
1968
+ // silent pass — dmarc.evaluate catches the parse throw → permerror.
1969
+ var dns = _txtOnly({ "_dmarc.example.com": [["v=DMARC1; p=maybe"]] });
1970
+ var rv = await b.mail.dmarc.evaluate({
1971
+ from: "alice@example.com",
1972
+ spf: { result: "pass", domain: "example.com" }, dkim: [], dnsLookup: dns,
1973
+ });
1974
+ check("dmarc.evaluate: unparseable published policy → permerror (not a pass)",
1975
+ rv.result === "permerror" && rv.alignment.spf === false);
1976
+ }
1977
+
1978
+ // ---- DMARC evaluate: alignment input shapes + modes ----
1979
+
1980
+ async function testDmarcEvaluateDkimResultShapes() {
1981
+ var dns = _txtOnly({ "_dmarc.example.com": [["v=DMARC1; p=reject; adkim=r; aspf=r"]] });
1982
+ // dkim supplied as a single object (not an array).
1983
+ var single = await b.mail.dmarc.evaluate({
1984
+ from: "alice@example.com", spf: { result: "fail", domain: "x" },
1985
+ dkim: { result: "pass", domain: "example.com" }, dnsLookup: dns,
1986
+ });
1987
+ check("dmarc.evaluate: dkim as a single object aligns → pass",
1988
+ single.result === "pass" && single.alignment.dkim === true);
1989
+ // dkim result carrying a `d` field instead of `domain`.
1990
+ var dField = await b.mail.dmarc.evaluate({
1991
+ from: "alice@example.com", spf: { result: "fail", domain: "x" },
1992
+ dkim: [{ result: "pass", d: "example.com" }], dnsLookup: dns,
1993
+ });
1994
+ check("dmarc.evaluate: dkim result exposing d= (not domain) still aligns → pass",
1995
+ dField.result === "pass" && dField.alignment.dkim === true);
1996
+ // A non-pass SPF result must not count as aligned even with a matching domain.
1997
+ var soft = await b.mail.dmarc.evaluate({
1998
+ from: "alice@example.com", spf: { result: "softfail", domain: "example.com" },
1999
+ dkim: [], dnsLookup: dns,
2000
+ });
2001
+ check("dmarc.evaluate: SPF softfail with matching domain is NOT aligned",
2002
+ soft.alignment.spf === false && soft.result === "fail");
2003
+ }
2004
+
2005
+ async function testDmarcEvaluateStrictVsRelaxedAlignment() {
2006
+ // aspf=s (strict): a subdomain From vs a parent-domain SPF auth-domain
2007
+ // does NOT align (exact host match required).
2008
+ var strictDns = _txtOnly({ "_dmarc.sub.example.com": [["v=DMARC1; p=reject; aspf=s; adkim=s"]] });
2009
+ var strict = await b.mail.dmarc.evaluate({
2010
+ from: "alice@sub.example.com", spf: { result: "pass", domain: "example.com" },
2011
+ dkim: [], dnsLookup: strictDns,
2012
+ });
2013
+ check("dmarc.evaluate: strict aspf=s — sub vs parent domain does NOT align → fail",
2014
+ strict.alignment.spf === false && strict.result === "fail");
2015
+ // aspf=r (relaxed): the same pair aligns via the shared org-domain.
2016
+ var relaxDns = _txtOnly({ "_dmarc.sub.example.com": [["v=DMARC1; p=reject; aspf=r"]] });
2017
+ var relaxed = await b.mail.dmarc.evaluate({
2018
+ from: "alice@sub.example.com", spf: { result: "pass", domain: "example.com" },
2019
+ dkim: [], dnsLookup: relaxDns,
2020
+ });
2021
+ check("dmarc.evaluate: relaxed aspf=r — sub vs parent domain aligns → pass",
2022
+ relaxed.alignment.spf === true && relaxed.result === "pass");
2023
+ }
2024
+
2025
+ // ---- ARC verify: input validation + uncovered chain-rule / time-fault arms ----
2026
+
2027
+ async function testArcVerifyInputValidation() {
2028
+ var e1 = await _rejectedWith(b.mail.arc.verify(12345));
2029
+ check("arc.verify: non-string input → arc-bad-input",
2030
+ e1 && /arc-bad-input/.test(e1.code || ""));
2031
+ var e2 = await _rejectedWith(b.mail.arc.verify(""));
2032
+ check("arc.verify: empty-string input → arc-bad-input",
2033
+ e2 && /arc-bad-input/.test(e2.code || ""));
2034
+ var e3 = await _rejectedWith(b.mail.arc.evaluate(12345, { trustedSealers: [] }));
2035
+ check("arc.evaluate: non-string input → arc-bad-input",
2036
+ e3 && /arc-bad-input/.test(e3.code || ""));
2037
+ }
2038
+
2039
+ async function testArcVerifyMissingCvArm() {
2040
+ // A structurally-complete i=1 hop whose ARC-Seal omits cv= trips the
2041
+ // missing-cv chain-rule arm (which takes precedence over the signature-
2042
+ // verification failure the dummy b= would otherwise report).
2043
+ var msg = "ARC-Seal: i=1; a=rsa-sha256; d=example.com; s=arc; b=AAAA\r\n" +
2044
+ "ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=arc; bh=AAAA; h=from; b=AAAA\r\n" +
2045
+ "ARC-Authentication-Results: i=1; example.com; spf=pass\r\n" +
2046
+ "From: alice@example.com\r\n\r\nbody\r\n";
2047
+ var rv = await b.mail.arc.verify(msg);
2048
+ check("arc.verify: ARC-Seal with no cv= → fail w/ missing-cv reason",
2049
+ rv.chainStatus === "fail" && /missing-cv-at-i=1/.test(rv.reason || ""));
2050
+ }
2051
+
2052
+ async function testArcVerifyTimeFaultArms() {
2053
+ // RFC 8617 §5.2 — a future t= and an unparseable x= are each time faults
2054
+ // that fail the AMS closed, independent of the (dummy) signature check.
2055
+ var keyDns = function () {
2056
+ return async function (qname) {
2057
+ if (qname === "arc._domainkey.example.com") {
2058
+ var nc = require("crypto");
2059
+ var pair = nc.generateKeyPairSync("rsa", { modulusLength: 2048 });
2060
+ return [["v=DKIM1; k=rsa; p=" +
2061
+ pair.publicKey.export({ type: "spki", format: "der" }).toString("base64")]];
2062
+ }
2063
+ var e = new Error("ENOTFOUND"); e.code = "ENOTFOUND"; throw e;
2064
+ };
2065
+ };
2066
+ var now = Math.floor(Date.now() / 1000);
2067
+ var futureMsg =
2068
+ "ARC-Seal: i=1; a=rsa-sha256; cv=none; d=example.com; s=arc; b=AAAA\r\n" +
2069
+ "ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=arc; t=" +
2070
+ (now + 999999) + "; bh=AAAA; h=from; b=AAAA\r\n" +
2071
+ "ARC-Authentication-Results: i=1; example.com; spf=pass\r\n" +
2072
+ "From: alice@example.com\r\nTo: bob@example.com\r\n\r\nbody\r\n";
2073
+ var rvF = await b.mail.arc.verify(futureMsg, { dnsLookup: keyDns() });
2074
+ var fErrs = ((rvF.hops[0] && rvF.hops[0].amsErrors) || []).join(" ; ");
2075
+ check("arc.verify: future t= → ams-t-future time fault (AMS fails closed)",
2076
+ rvF.chainStatus === "fail" && /ams-t-future/.test(fErrs));
2077
+
2078
+ var unparseMsg =
2079
+ "ARC-Seal: i=1; a=rsa-sha256; cv=none; d=example.com; s=arc; b=AAAA\r\n" +
2080
+ "ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=arc; x=notanumber; bh=AAAA; h=from; b=AAAA\r\n" +
2081
+ "ARC-Authentication-Results: i=1; example.com; spf=pass\r\n" +
2082
+ "From: alice@example.com\r\nTo: bob@example.com\r\n\r\nbody\r\n";
2083
+ var rvU = await b.mail.arc.verify(unparseMsg, { dnsLookup: keyDns() });
2084
+ var uErrs = ((rvU.hops[0] && rvU.hops[0].amsErrors) || []).join(" ; ");
2085
+ check("arc.verify: unparseable x= → ams-x-unparseable time fault (fails closed)",
2086
+ rvU.chainStatus === "fail" && /ams-x-unparseable/.test(uErrs));
2087
+ }
2088
+
2089
+ // ---- authResults.emit (RFC 8601 §2) — the whole builder is otherwise
2090
+ // exercised only indirectly through inbound.verify ----
2091
+
2092
+ function testAuthResultsEmitValidation() {
2093
+ var E = b.mail.authResults.emit;
2094
+ var eObj = _threw(function () { E(null); });
2095
+ check("authResults.emit: non-object opts → ar-bad-input",
2096
+ eObj && /ar-bad-input/.test(eObj.code || ""));
2097
+ var eOpt = _threw(function () { E({ authservId: "mx.a", results: [], bogus: 1 }); });
2098
+ check("authResults.emit: unknown opt refused (config-time)", eOpt !== null);
2099
+ var eEmpty = _threw(function () { E({ authservId: "", results: [] }); });
2100
+ check("authResults.emit: empty authservId → ar-bad-authserv-id",
2101
+ eEmpty && /ar-bad-authserv-id/.test(eEmpty.code || ""));
2102
+ var eCrlf = _threw(function () { E({ authservId: "mx.a\r\nInjected: 1", results: [] }); });
2103
+ check("authResults.emit: CR/LF in authservId → ar-bad-authserv-id (no header injection)",
2104
+ eCrlf && /ar-bad-authserv-id/.test(eCrlf.code || ""));
2105
+ var eNul = _threw(function () { E({ authservId: "mx.a" + String.fromCharCode(0) + "x", results: [] }); });
2106
+ check("authResults.emit: NUL in authservId → ar-bad-authserv-id",
2107
+ eNul && /ar-bad-authserv-id/.test(eNul.code || ""));
2108
+ var eArr = _threw(function () { E({ authservId: "mx.a", results: "nope" }); });
2109
+ check("authResults.emit: non-array results → ar-bad-results",
2110
+ eArr && /ar-bad-results/.test(eArr.code || ""));
2111
+ var eEntry = _threw(function () { E({ authservId: "mx.a", results: [42] }); });
2112
+ check("authResults.emit: non-object result entry → ar-bad-result-entry",
2113
+ eEntry && /ar-bad-result-entry/.test(eEntry.code || ""));
2114
+ var eMethod = _threw(function () { E({ authservId: "mx.a", results: [{ method: "bogus", result: "pass" }] }); });
2115
+ check("authResults.emit: unknown method → ar-bad-method",
2116
+ eMethod && /ar-bad-method/.test(eMethod.code || ""));
2117
+ // RFC 8601 §2.7 — result vocabulary is method-specific: 'hardfail' is
2118
+ // not in SPF's set, so it is refused rather than passed through.
2119
+ var eResult = _threw(function () { E({ authservId: "mx.a", results: [{ method: "spf", result: "hardfail" }] }); });
2120
+ check("authResults.emit: result outside a method's §2.7 vocabulary → ar-bad-result",
2121
+ eResult && /ar-bad-result/.test(eResult.code || ""));
2122
+ }
2123
+
2124
+ function testAuthResultsEmitFormatting() {
2125
+ var E = b.mail.authResults.emit;
2126
+ // Zero methods evaluated → the RFC 8601 §2.2 `none` form.
2127
+ check("authResults.emit: empty results → '; none'",
2128
+ E({ authservId: "mx.a", results: [] }) === "Authentication-Results: mx.a; none");
2129
+ // ptype.property=value triples for the recognized shorthand keys.
2130
+ var props = E({ authservId: "mx.a", results: [
2131
+ { method: "spf", result: "pass", smtpMailfrom: "u@s.example" },
2132
+ { method: "dmarc", result: "pass", from: "u@s.example" },
2133
+ ] });
2134
+ check("authResults.emit: property keys mapped to RFC 8601 §2.3 ptype.property",
2135
+ /spf=pass smtp\.mailfrom=u@s\.example/.test(props) &&
2136
+ /dmarc=pass header\.from=u@s\.example/.test(props));
2137
+ // A reason string with an embedded DQUOTE is backslash-escaped (§2.2).
2138
+ var reason = E({ authservId: "mx.a", results: [{ method: "dkim", result: "fail", reason: 'key "rotated"' }] });
2139
+ check("authResults.emit: reason DQUOTE escaped as \\\" (RFC 8601 §2.2)",
2140
+ reason.indexOf('reason="key \\"rotated\\""') !== -1);
2141
+ // A pvalue carrying a forbidden character (space) is dropped, not emitted.
2142
+ var pv = E({ authservId: "mx.a", results: [{ method: "dkim", result: "pass", domain: "has space" }] });
2143
+ check("authResults.emit: structurally-invalid pvalue is omitted (not injected)",
2144
+ pv.indexOf("header.d=") === -1 && /dkim=pass/.test(pv));
2145
+ // fold:false switches the clause separator from the folded form to '; '.
2146
+ var flat = E({ authservId: "mx.a", fold: false,
2147
+ results: [{ method: "spf", result: "pass" }, { method: "dkim", result: "pass" }] });
2148
+ check("authResults.emit: fold:false joins clauses with '; ' (no folded inter-clause break)",
2149
+ /spf=pass; dkim=pass/.test(flat) && flat.indexOf(";\r\n dkim") === -1);
2150
+ // A non-'1' version is appended after the authserv-id token (§2.2).
2151
+ var ver = E({ authservId: "mx.a", version: "2", results: [{ method: "spf", result: "pass" }] });
2152
+ check("authResults.emit: version appended to authserv-id when not '1'",
2153
+ ver.indexOf("Authentication-Results: mx.a 2;") === 0);
2154
+ }
2155
+
2156
+ // A dnsLookup fake that throws a transient (non-absence) resolver fault
2157
+ // for every query — drives the temperror classification arms.
2158
+ function _transientDns() {
2159
+ return async function () {
2160
+ var e = new Error("resolver server failure"); e.code = "ESERVFAIL"; throw e;
2161
+ };
2162
+ }
2163
+
2164
+ function _enotfound() {
2165
+ var e = new Error("ENOTFOUND"); e.code = "ENOTFOUND"; throw e;
2166
+ }
2167
+
2168
+ // Run one SPF exists: policy and capture the expanded A-query names.
2169
+ async function _spfExistsExpand(record, mailFrom, ip) {
2170
+ var queried = [];
2171
+ var dns = async function (host, type) {
2172
+ if (type === "TXT" && host === "s.example") return [[record]];
2173
+ if (type === "A") { queried.push(host); return ["127.0.0.2"]; }
2174
+ return _enotfound();
2175
+ };
2176
+ var rv = await b.mail.spf.verify({
2177
+ ip: ip || "192.0.2.9", mailFrom: mailFrom, dnsLookup: dns,
2178
+ });
2179
+ return { result: rv.result, queried: queried, explanation: rv.explanation };
2180
+ }
2181
+
2182
+ // ---- SPF: macro escapes, letters, and custom delimiters (RFC 7208 §7) ----
2183
+
2184
+ async function testSpfMacroEscapesAndLetters() {
2185
+ var mf = "alice@s.example";
2186
+ // `%-` legacy escape expands to the literal "%20" (RFC 7208 §7.1).
2187
+ var hyphen = await _spfExistsExpand("v=spf1 exists:a%-b.probe -all", mf);
2188
+ check("spf macro %- → literal %20",
2189
+ hyphen.result === "pass" && hyphen.queried.indexOf("a%20b.probe") !== -1);
2190
+ // `%{s}` — the whole sender identity (local@domain).
2191
+ var sender = await _spfExistsExpand("v=spf1 exists:%{s}.probe -all", mf);
2192
+ check("spf macro %{s} → full sender identity",
2193
+ sender.result === "pass" && sender.queried.indexOf("alice@s.example.probe") !== -1);
2194
+ // `%{p}` — validated-domain sentinel; the framework returns "unknown"
2195
+ // rather than performing the discouraged §5.5 reverse-lookup.
2196
+ var pMac = await _spfExistsExpand("v=spf1 exists:%{p}.probe -all", mf);
2197
+ check("spf macro %{p} → RFC 7208 §5.5 'unknown' sentinel",
2198
+ pMac.result === "pass" && pMac.queried.indexOf("unknown.probe") !== -1);
2199
+ // `%{c}` — an exp-text-only letter; empty in mechanism context (§7.3).
2200
+ var cMac = await _spfExistsExpand("v=spf1 exists:x%{c}y.probe -all", mf);
2201
+ check("spf macro %{c} (exp-only letter) → empty in mechanism context",
2202
+ cMac.result === "pass" && cMac.queried.indexOf("xy.probe") !== -1);
2203
+ // Custom delimiter set: `%{l+}` splits the local-part on `+` and
2204
+ // re-joins with `.` (RFC 7208 §7.1 transformer delimiter set).
2205
+ var delim = await _spfExistsExpand("v=spf1 exists:%{l+}.probe -all", "a+b@s.example");
2206
+ check("spf macro %{l+} → custom '+' split delimiter re-joined with '.'",
2207
+ delim.result === "pass" && delim.queried.indexOf("a.b.probe") !== -1);
2208
+ }
2209
+
2210
+ // ---- SPF: IPv6 macro letters (RFC 7208 §7.3) ----
2211
+
2212
+ async function testSpfMacroIpv6Letters() {
2213
+ var mf = "u@s.example";
2214
+ var v = await _spfExistsExpand("v=spf1 exists:%{v}.probe -all", mf, "2001:db8::1");
2215
+ check("spf macro %{v} on IPv6 connection → 'ip6'",
2216
+ v.result === "pass" && v.queried.indexOf("ip6.probe") !== -1);
2217
+ var iMac = await _spfExistsExpand("v=spf1 exists:%{i}.probe -all", mf, "2001:db8::1");
2218
+ check("spf macro %{i} on IPv6 → nibble-dotted 32-part form",
2219
+ iMac.result === "pass" &&
2220
+ iMac.queried.indexOf("2.0.0.1.0.d.b.8.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.probe") !== -1);
2221
+ }
2222
+
2223
+ // ---- SPF: additional malformed-macro syntax arms (RFC 7208 §7.1) ----
2224
+
2225
+ async function testSpfMacroBadSyntaxMore() {
2226
+ async function bad(record) {
2227
+ var dns = _txtOnly({ "s.example": [[record]] });
2228
+ var rv = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: dns });
2229
+ return rv;
2230
+ }
2231
+ var bare = await bad("v=spf1 exists:foo% -all");
2232
+ check("spf macro: trailing bare '%' → permerror",
2233
+ bare.result === "permerror" && /bare '%'/.test(bare.explanation || ""));
2234
+ var digit0 = await bad("v=spf1 exists:%{s0} -all");
2235
+ check("spf macro: transformer digit count 0 → permerror",
2236
+ digit0.result === "permerror" && /digit count must be >= 1/.test(digit0.explanation || ""));
2237
+ var badDelim = await bad("v=spf1 exists:%{d!} -all");
2238
+ check("spf macro: delimiter outside the §7.1 set → permerror",
2239
+ badDelim.result === "permerror" && /not in the RFC 7208 §7\.1 set/.test(badDelim.explanation || ""));
2240
+ }
2241
+
2242
+ // ---- SPF: CIDR mask edges — /0 (match-all) + partial-group prefix ----
2243
+
2244
+ async function testSpfCidrMaskEdges() {
2245
+ // ip4:0.0.0.0/0 — a /0 authorizes every IPv4 (mask 0 → unconditional).
2246
+ var dns4 = _txtOnly({ "s.example": [["v=spf1 ip4:0.0.0.0/0 -all"]] });
2247
+ var r4 = await b.mail.spf.verify({ ip: "203.0.113.9", mailFrom: "a@s.example", dnsLookup: dns4 });
2248
+ check("spf ip4:0.0.0.0/0 → pass (mask 0 matches any IPv4)", r4.result === "pass");
2249
+
2250
+ // ip6 partial-group prefix (/36): compares full groups + a masked
2251
+ // remainder-bit nibble, exercising the non-16-aligned mask arm.
2252
+ var dns36 = _txtOnly({ "s.example": [["v=spf1 ip6:2001:db8:8000::/36 -all"]] });
2253
+ var in36 = await b.mail.spf.verify({ ip: "2001:db8:8fff::1", mailFrom: "a@s.example", dnsLookup: dns36 });
2254
+ check("spf ip6:.../36 — IP inside the partial-group prefix → pass", in36.result === "pass");
2255
+ var out36 = await b.mail.spf.verify({ ip: "2001:db8:0fff::1", mailFrom: "a@s.example", dnsLookup: dns36 });
2256
+ check("spf ip6:.../36 — IP outside the partial-group prefix → fail (-all)", out36.result === "fail");
2257
+
2258
+ // ip6 /0 — mask 0 authorizes any IPv6.
2259
+ var dns6z = _txtOnly({ "s.example": [["v=spf1 ip6:2001:db8::/0 -all"]] });
2260
+ var r6z = await b.mail.spf.verify({ ip: "fe80::1", mailFrom: "a@s.example", dnsLookup: dns6z });
2261
+ check("spf ip6:.../0 → pass (mask 0 matches any IPv6)", r6z.result === "pass");
2262
+ }
2263
+
2264
+ // ---- SPF: multiple published records → permerror (RFC 7208 §4.5) ----
2265
+
2266
+ async function testSpfMultipleRecordsPermerror() {
2267
+ var dns = _txtOnly({ "s.example": [["v=spf1 -all"], ["v=spf1 +all"]] });
2268
+ var rv = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: dns });
2269
+ check("spf.verify: two published v=spf1 records → permerror (RFC 7208 §4.5)",
2270
+ rv.result === "permerror" && /at most one/.test(rv.explanation || ""));
2271
+ }
2272
+
2273
+ // ---- SPF: transient TXT lookup fault → temperror ----
2274
+
2275
+ async function testSpfLookupFailureTemperror() {
2276
+ var rv = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: _transientDns() });
2277
+ check("spf.verify: transient TXT resolver fault → temperror (a verdict, not a throw)",
2278
+ rv.result === "temperror" && /lookup for s\.example failed/.test(rv.explanation || ""));
2279
+ }
2280
+
2281
+ // ---- SPF: DNS-lookup ceiling exceeded (RFC 7208 §4.6.4) ----
2282
+
2283
+ async function testSpfLookupLimitExceeded() {
2284
+ // Eleven DNS-touching `a` mechanisms — the 11th crosses the 10-lookup
2285
+ // ceiling. Each A resolves to a non-matching address so the loop keeps
2286
+ // consuming lookups instead of short-circuiting on a match.
2287
+ var dns = async function (host, type) {
2288
+ if (type === "TXT" && host === "s.example") return [["v=spf1 a a a a a a a a a a a -all"]];
2289
+ if (type === "A") return ["198.51.100.1"];
2290
+ return _enotfound();
2291
+ };
2292
+ var rv = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: dns });
2293
+ check("spf.verify: 11 DNS mechanisms → permerror (RFC 7208 §4.6.4 lookup ceiling)",
2294
+ rv.result === "permerror" && /DNS lookup limit exceeded/.test(rv.explanation || ""));
2295
+ }
2296
+
2297
+ // ---- SPF: a / mx DNS-failure + macro arms ----
2298
+
2299
+ async function testSpfAMxErrorBranches() {
2300
+ // a: with a bad-macro domain-spec → permerror (the §7 expansion throws).
2301
+ var badMacro = _txtOnly({ "s.example": [["v=spf1 a:%{z} -all"]] });
2302
+ var rBad = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: badMacro });
2303
+ check("spf a:%{z} — invalid macro-letter in domain-spec → permerror",
2304
+ rBad.result === "permerror" && /not a valid macro-letter/.test(rBad.explanation || ""));
2305
+
2306
+ // a: whose domain-spec macro-expands to the empty string → permerror.
2307
+ var emptyMacro = _txtOnly({ "s.example": [["v=spf1 a:%{c} -all"]] });
2308
+ var rEmpty = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: emptyMacro });
2309
+ check("spf a:%{c} — domain-spec expands to empty → permerror",
2310
+ rEmpty.result === "permerror" && /expanded to empty/.test(rEmpty.explanation || ""));
2311
+
2312
+ // a — transient A lookup fault → temperror.
2313
+ var aTemp = async function (host, type) {
2314
+ if (type === "TXT" && host === "s.example") return [["v=spf1 a -all"]];
2315
+ if (type === "A") { var e = new Error("srv"); e.code = "ESERVFAIL"; throw e; }
2316
+ return _enotfound();
2317
+ };
2318
+ var rATemp = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: aTemp });
2319
+ check("spf a — transient A lookup fault → temperror",
2320
+ rATemp.result === "temperror" && /a:s\.example lookup failed/.test(rATemp.explanation || ""));
2321
+
2322
+ // mx — transient MX lookup fault → temperror.
2323
+ var mxTemp = async function (host, type) {
2324
+ if (type === "TXT" && host === "s.example") return [["v=spf1 mx -all"]];
2325
+ if (type === "MX") { var e = new Error("srv"); e.code = "ESERVFAIL"; throw e; }
2326
+ return _enotfound();
2327
+ };
2328
+ var rMxTemp = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: mxTemp });
2329
+ check("spf mx — transient MX lookup fault → temperror",
2330
+ rMxTemp.result === "temperror" && /mx:s\.example MX lookup failed/.test(rMxTemp.explanation || ""));
2331
+
2332
+ // mx — MX host A resolution transient fault → temperror.
2333
+ var mxHostTemp = async function (host, type) {
2334
+ if (type === "TXT" && host === "s.example") return [["v=spf1 mx -all"]];
2335
+ if (type === "MX") return [{ exchange: "mx1.s.example", preference: 10 }];
2336
+ if (type === "A") { var e = new Error("srv"); e.code = "ESERVFAIL"; throw e; }
2337
+ return _enotfound();
2338
+ };
2339
+ var rMxHostTemp = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: mxHostTemp });
2340
+ check("spf mx — MX-host A resolution fault → temperror",
2341
+ rMxHostTemp.result === "temperror" && /mx host mx1\.s\.example A\/AAAA lookup failed/.test(rMxHostTemp.explanation || ""));
2342
+
2343
+ // mx — MX host with no A record (void) is a miss, not an error → -all fail.
2344
+ var mxHostVoid = async function (host, type) {
2345
+ if (type === "TXT" && host === "s.example") return [["v=spf1 mx -all"]];
2346
+ if (type === "MX") return [{ exchange: "mx1.s.example", preference: 10 }];
2347
+ return _enotfound();
2348
+ };
2349
+ var rMxVoid = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: mxHostVoid });
2350
+ check("spf mx — MX host with no A record → miss, falls to -all fail",
2351
+ rMxVoid.result === "fail");
2352
+ }
2353
+
2354
+ // ---- SPF: exists transient lookup fault → temperror ----
2355
+
2356
+ async function testSpfExistsTemperror() {
2357
+ var dns = async function (host, type) {
2358
+ if (type === "TXT" && host === "s.example") return [["v=spf1 exists:p.%{d} -all"]];
2359
+ if (type === "A") { var e = new Error("srv"); e.code = "ESERVFAIL"; throw e; }
2360
+ return _enotfound();
2361
+ };
2362
+ var rv = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: dns });
2363
+ check("spf exists — transient A lookup fault → temperror",
2364
+ rv.result === "temperror" && /exists:p\.s\.example lookup failed/.test(rv.explanation || ""));
2365
+ }
2366
+
2367
+ // ---- SPF: include macro + transient arms ----
2368
+
2369
+ async function testSpfIncludeErrorBranches() {
2370
+ // include target is itself a bad macro-string → permerror.
2371
+ var badMacro = _txtOnly({ "s.example": [["v=spf1 include:%{z} -all"]] });
2372
+ var rBad = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: badMacro });
2373
+ check("spf include:%{z} — invalid macro-letter → permerror",
2374
+ rBad.result === "permerror" && /not a valid macro-letter/.test(rBad.explanation || ""));
2375
+
2376
+ // include target's TXT resolution transiently faults → temperror propagates.
2377
+ var incTemp = async function (host, type) {
2378
+ if (type === "TXT" && host === "s.example") return [["v=spf1 include:inner.example -all"]];
2379
+ if (type === "TXT" && host === "inner.example") { var e = new Error("srv"); e.code = "ESERVFAIL"; throw e; }
2380
+ return _enotfound();
2381
+ };
2382
+ var rTemp = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: incTemp });
2383
+ check("spf include — inner transient TXT fault → temperror propagates to outer verdict",
2384
+ rTemp.result === "temperror" && /lookup for inner\.example failed/.test(rTemp.explanation || ""));
2385
+ }
2386
+
2387
+ // ---- DMARC: transient _dmarc lookup fault → temperror ----
2388
+
2389
+ async function testDmarcTemperror() {
2390
+ var rv = await b.mail.dmarc.evaluate({
2391
+ from: "u@x.example", spf: { result: "fail", domain: "x" }, dkim: [], dnsLookup: _transientDns(),
2392
+ });
2393
+ check("dmarc.evaluate: transient _dmarc lookup fault → temperror (not a silent pass)",
2394
+ rv.result === "temperror" && rv.policy === null &&
2395
+ rv.alignment.spf === false && rv.alignment.dkim === false);
2396
+ }
2397
+
2398
+ // ---- DMARC: pct< 100 sampled disposition (RFC 7489 §6.6.4) ----
2399
+
2400
+ async function testDmarcPctSampledDispositions() {
2401
+ // The sample roll is a deterministic SHAKE256 of pctSampleKey → [0,100).
2402
+ // Recompute it here and publish pct = roll so `roll >= pct` holds → the
2403
+ // failing message lands in the SAMPLED (next-less-strict) fraction.
2404
+ var key = "sampled-disposition-key";
2405
+ var digest = nodeCrypto.createHash("shake256", { outputLength: 4 }).update(key).digest();
2406
+ var u32 = (digest[0] << 24 >>> 0) + (digest[1] << 16) + (digest[2] << 8) + digest[3];
2407
+ var roll = u32 % 100;
2408
+
2409
+ var rejectDns = _txtOnly({ "_dmarc.example.com": [["v=DMARC1; p=reject; pct=" + roll]] });
2410
+ var reject = await b.mail.dmarc.evaluate({
2411
+ from: "a@example.com", spf: { result: "fail", domain: "x" }, dkim: [],
2412
+ dnsLookup: rejectDns, pctSampleKey: key,
2413
+ });
2414
+ check("dmarc.evaluate: p=reject, sampled fraction → recommendedAction quarantine (§6.6.4 step-down)",
2415
+ reject.result === "fail" && reject.recommendedAction === "quarantine");
2416
+
2417
+ var quarDns = _txtOnly({ "_dmarc.example.com": [["v=DMARC1; p=quarantine; pct=" + roll]] });
2418
+ var quar = await b.mail.dmarc.evaluate({
2419
+ from: "a@example.com", spf: { result: "fail", domain: "x" }, dkim: [],
2420
+ dnsLookup: quarDns, pctSampleKey: key,
2421
+ });
2422
+ check("dmarc.evaluate: p=quarantine, sampled fraction → recommendedAction none (§6.6.4 step-down)",
2423
+ quar.result === "fail" && quar.recommendedAction === "none");
2424
+ }
2425
+
2426
+ // ---- DMARC aggregate report: pre-parsed / bad-input / cap arms ----
2427
+
2428
+ async function testDmarcAggregatePreParsedAndErrors() {
2429
+ // Non-Buffer / non-string / non-feedback input → typed throw.
2430
+ var eIn = _threw(function () { b.mail.dmarc.parseAggregateReport(12345); });
2431
+ check("dmarc.parseAggregateReport: numeric input → dmarc-rua-bad-input",
2432
+ eIn && /dmarc-rua-bad-input/.test(eIn.code || ""));
2433
+
2434
+ // Pre-parsed object shortcut: skips parse, shapes directly + aggregates
2435
+ // the per-record totals.
2436
+ var shaped = b.mail.dmarc.parseAggregateReport({
2437
+ feedback: {
2438
+ report_metadata: { org_name: "acme", report_id: "r-1", date_range: { begin: "1000", end: "2000" } },
2439
+ policy_published: { domain: "d.example", p: "none", pct: "100" },
2440
+ record: [
2441
+ { row: { source_ip: "1.2.3.4", count: "5",
2442
+ policy_evaluated: { disposition: "none", dkim: "pass", spf: "fail" } },
2443
+ identifiers: { header_from: "d.example" },
2444
+ auth_results: { dkim: { domain: "d.example", selector: "s", result: "pass" },
2445
+ spf: { domain: "d.example", result: "pass" } } },
2446
+ { row: { source_ip: "5.6.7.8", count: "3",
2447
+ policy_evaluated: { disposition: "reject", dkim: "fail", spf: "fail",
2448
+ reason: { type: "trusted_forwarder", comment: "fwd" } } },
2449
+ identifiers: { header_from: "d.example", envelope_from: "d.example" },
2450
+ auth_results: { dkim: [], spf: [] } },
2451
+ ],
2452
+ },
2453
+ });
2454
+ check("dmarc.parseAggregateReport: pre-parsed object shortcut shapes metadata + records",
2455
+ shaped.reportMetadata.orgName === "acme" && shaped.records.length === 2 &&
2456
+ shaped.reportMetadata.dateRange.begin === 1000);
2457
+ check("dmarc.parseAggregateReport: totals aggregate aligned vs not-aligned by count",
2458
+ shaped.totals.messages === 8 && shaped.totals.aligned === 5 && shaped.totals.notAligned === 3);
2459
+ check("dmarc.parseAggregateReport: per-record policy_evaluated reasons shaped",
2460
+ shaped.records[1].dispositions.reasons.length === 1 &&
2461
+ shaped.records[1].dispositions.reasons[0].type === "trusted_forwarder");
2462
+
2463
+ // Over the per-report record cap → typed throw.
2464
+ var big = { feedback: { report_metadata: {}, policy_published: {}, record: [] } };
2465
+ for (var i = 0; i < 10001; i += 1) big.feedback.record.push({});
2466
+ var eCap = _threw(function () { b.mail.dmarc.parseAggregateReport(big); });
2467
+ check("dmarc.parseAggregateReport: over the 10000-record cap → dmarc-rua-too-many-records",
2468
+ eCap && /dmarc-rua-too-many-records/.test(eCap.code || ""));
2469
+
2470
+ // Parsed XML whose root is not <feedback> → typed throw.
2471
+ var eNoFb = _threw(function () { b.mail.dmarc.parseAggregateReport("<other></other>"); });
2472
+ check("dmarc.parseAggregateReport: non-<feedback> XML root → dmarc-rua-no-feedback",
2473
+ eNoFb && /dmarc-rua-no-feedback/.test(eNoFb.code || ""));
2474
+ }
2475
+
2476
+ // ---- ARC: per-hop key-material failure arms (RFC 8617 §5.1) ----
2477
+
2478
+ function _arcKeyDns(qname, records) {
2479
+ return async function (q) {
2480
+ if (q === qname) return records;
2481
+ return _enotfound();
2482
+ };
2483
+ }
2484
+
2485
+ async function testArcKeyErrorBranches() {
2486
+ var from = "From: alice@example.com\r\nTo: bob@example.com\r\n\r\nbody\r\n";
2487
+
2488
+ // AMS missing its d= tag → the AMS reports "missing required tag(s)";
2489
+ // the AS (all tags present) fails its key lookup → permerror.
2490
+ var missingTags =
2491
+ "ARC-Seal: i=1; a=rsa-sha256; cv=none; d=example.com; s=arc; b=AAAA\r\n" +
2492
+ "ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; s=arc; bh=AAAA; h=from; b=AAAA\r\n" +
2493
+ "ARC-Authentication-Results: i=1; example.com; spf=pass\r\n" + from;
2494
+ var rMiss = await b.mail.arc.verify(missingTags, { dnsLookup: async function () { return _enotfound(); } });
2495
+ var missAmsErrs = ((rMiss.hops[0] || {}).amsErrors || []).join(" ; ");
2496
+ check("arc.verify: AMS missing d/s/b/a → missing-required-tag(s) + chain fail",
2497
+ rMiss.chainStatus === "fail" && /missing required tag/.test(missAmsErrs));
2498
+
2499
+ // AMS unsupported signature algorithm → permerror.
2500
+ var unsupAlg =
2501
+ "ARC-Seal: i=1; a=rsa-sha256; cv=none; d=example.com; s=arc; b=AAAA\r\n" +
2502
+ "ARC-Message-Signature: i=1; a=magic-alg; c=relaxed/relaxed; d=example.com; s=arc; bh=AAAA; h=from; b=AAAA\r\n" +
2503
+ "ARC-Authentication-Results: i=1; example.com; spf=pass\r\n" + from;
2504
+ var rAlg = await b.mail.arc.verify(unsupAlg, { dnsLookup: async function () { return _enotfound(); } });
2505
+ var algErrs = ((rAlg.hops[0] || {}).amsErrors || []).join(" ; ");
2506
+ check("arc.verify: AMS unsupported alg → permerror + chain fail",
2507
+ rAlg.chainStatus === "fail" && /unsupported alg 'magic-alg'/.test(algErrs));
2508
+
2509
+ // A structurally-complete hop with valid tags.
2510
+ var fullHop =
2511
+ "ARC-Seal: i=1; a=rsa-sha256; cv=none; d=example.com; s=arc; b=AAAA\r\n" +
2512
+ "ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=arc; bh=AAAA; h=from; b=AAAA\r\n" +
2513
+ "ARC-Authentication-Results: i=1; example.com; spf=pass\r\n" + from;
2514
+
2515
+ // Published key record lacks p= → permerror.
2516
+ var rNoP = await b.mail.arc.verify(fullHop,
2517
+ { dnsLookup: _arcKeyDns("arc._domainkey.example.com", [["v=DKIM1; k=rsa"]]) });
2518
+ var noPErrs = ((rNoP.hops[0] || {}).asErrors || []).join(" ; ");
2519
+ check("arc.verify: key record missing p= → permerror",
2520
+ rNoP.chainStatus === "fail" && /key record missing p=/.test(noPErrs));
2521
+
2522
+ // Published p= is well-formed base64 but not a valid SPKI key → the AS
2523
+ // key parse fails → permerror.
2524
+ var rBadKey = await b.mail.arc.verify(fullHop,
2525
+ { dnsLookup: _arcKeyDns("arc._domainkey.example.com", [["v=DKIM1; k=rsa; p=AAAABBBB"]]) });
2526
+ var badKeyErrs = ((rBadKey.hops[0] || {}).asErrors || []).join(" ; ");
2527
+ check("arc.verify: unparseable key material → key-parse-failed permerror",
2528
+ rBadKey.chainStatus === "fail" && /key parse failed/.test(badKeyErrs));
2529
+
2530
+ // Key lookup returns nothing (ENOTFOUND) on a valid-tag hop → permerror.
2531
+ var rNoKey = await b.mail.arc.verify(fullHop, { dnsLookup: async function () { return _enotfound(); } });
2532
+ check("arc.verify: key lookup ENOTFOUND → permerror (definitive absence, not temperror)",
2533
+ rNoKey.chainStatus === "fail" &&
2534
+ (rNoKey.hops[0] || {}).asResult === "permerror" &&
2535
+ (rNoKey.hops[0] || {}).amsResult === "permerror");
2536
+ }
2537
+
2538
+ // ---- inbound.verify: no-identity + helo-only arms ----
2539
+
2540
+ async function testInboundNoIdentitySpfNone() {
2541
+ // Neither MAIL FROM nor HELO → SPF short-circuits to `none` without any
2542
+ // envelope-identity lookup. Keep the whole call offline with an
2543
+ // absence-only resolver so DMARC also resolves no record.
2544
+ var msg = "From: alice@example.com\r\nTo: bob@x\r\nSubject: hi\r\n\r\nbody\r\n";
2545
+ var v = await b.mail.inbound.verify({
2546
+ ip: "203.0.113.5", message: msg, dnsLookup: async function () { return _enotfound(); },
2547
+ });
2548
+ check("inbound.verify: no MAIL FROM / HELO → spf none (no identity lookup)",
2549
+ v.spf.result === "none" && /no MAIL FROM or HELO identity/.test(v.spf.explanation || ""));
2550
+ check("inbound.verify: no-identity + no DMARC record → dmarc none",
2551
+ v.dmarc.result === "none");
2552
+ }
2553
+
2554
+ async function testInboundHeloOnlyAuthResults() {
2555
+ // HELO-only envelope: the emitted Authentication-Results carries the
2556
+ // spf clause with an smtp.helo property (not smtp.mailfrom).
2557
+ var msg = "From: alice@example.com\r\nTo: bob@x\r\nSubject: hi\r\n\r\nbody\r\n";
2558
+ var dns = _txtOnly({
2559
+ "mail.example.com": [["v=spf1 ip4:203.0.113.0/24 -all"]],
2560
+ "_dmarc.example.com": [["v=DMARC1; p=none"]],
2561
+ });
2562
+ var v = await b.mail.inbound.verify({
2563
+ ip: "203.0.113.5", helo: "mail.example.com", message: msg,
2564
+ authservId: "mx.receiver.example", dnsLookup: dns,
2565
+ });
2566
+ check("inbound.verify: HELO-only identity → spf pass on the HELO domain",
2567
+ v.spf.result === "pass");
2568
+ check("inbound.verify: A-R spf clause carries smtp.helo (not smtp.mailfrom)",
2569
+ typeof v.authResults === "string" &&
2570
+ /spf=pass smtp\.helo=mail\.example\.com/.test(v.authResults) &&
2571
+ v.authResults.indexOf("smtp.mailfrom=") === -1);
2572
+ }
2573
+
2574
+ // ---- SPF: a/mx operator-lookup empty / non-array answers (ENODATA) ----
2575
+
2576
+ async function testSpfAMxOperatorEnodata() {
2577
+ // RFC 7208 §5.3/§5.4 — an operator dnsLookup that RETURNS (not throws)
2578
+ // an empty or non-array A/AAAA / MX answer is an ENODATA void, not an
2579
+ // error: the mechanism misses and evaluation falls through to -all.
2580
+ var aEmpty = async function (host, type) {
2581
+ if (type === "TXT" && host === "s.example") return [["v=spf1 a -all"]];
2582
+ if (type === "A") return []; // successful, zero records
2583
+ return _enotfound();
2584
+ };
2585
+ var rAE = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: aEmpty });
2586
+ check("spf a — operator returns empty A array → miss → -all fail",
2587
+ rAE.result === "fail");
2588
+
2589
+ var aNonArray = async function (host, type) {
2590
+ if (type === "TXT" && host === "s.example") return [["v=spf1 a -all"]];
2591
+ if (type === "A") return null; // malformed non-array answer
2592
+ return _enotfound();
2593
+ };
2594
+ var rAN = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: aNonArray });
2595
+ check("spf a — operator returns non-array A answer → treated as ENODATA miss → fail",
2596
+ rAN.result === "fail");
2597
+
2598
+ var mxEmpty = async function (host, type) {
2599
+ if (type === "TXT" && host === "s.example") return [["v=spf1 mx -all"]];
2600
+ if (type === "MX") return []; // successful, zero records
2601
+ return _enotfound();
2602
+ };
2603
+ var rME = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: mxEmpty });
2604
+ check("spf mx — operator returns empty MX array → miss → -all fail",
2605
+ rME.result === "fail");
2606
+
2607
+ var mxNonArray = async function (host, type) {
2608
+ if (type === "TXT" && host === "s.example") return [["v=spf1 mx -all"]];
2609
+ if (type === "MX") return null; // malformed non-array answer
2610
+ return _enotfound();
2611
+ };
2612
+ var rMN = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: mxNonArray });
2613
+ check("spf mx — operator returns non-array MX answer → treated as ENODATA miss → fail",
2614
+ rMN.result === "fail");
2615
+
2616
+ // MX normalization (RFC 1035 §3.3.9): a mixed answer of a bare exchange
2617
+ // string (preference defaulted to 0) and a { exchange, preference }
2618
+ // object with trailing dots is normalized + preference-sorted; the
2619
+ // lowest-preference host resolves to the connecting IP → pass.
2620
+ var mxMixed = async function (host, type) {
2621
+ if (type === "TXT" && host === "s.example") return [["v=spf1 mx -all"]];
2622
+ if (type === "MX") return [{ exchange: "mx2.s.example.", preference: 20 }, "mx1.s.example."];
2623
+ if (type === "A" && host === "mx1.s.example") return ["192.0.2.5"];
2624
+ if (type === "A" && host === "mx2.s.example") return ["10.0.0.9"];
2625
+ return _enotfound();
2626
+ };
2627
+ var rMM = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: mxMixed });
2628
+ check("spf mx — mixed string/object MX answer normalizes + sorts → matching host → pass",
2629
+ rMM.result === "pass");
2630
+ }
2631
+
2632
+ // ---- SPF: a/mx dual-cidr-length grammar edges (RFC 7208 §5.3/§5.4) ----
2633
+
2634
+ async function testSpfADualCidrErrorArms() {
2635
+ // v4 cidr-length out of range (>32) → permerror.
2636
+ var badV4 = _txtOnly({ "s.example": [["v=spf1 a/33 -all"]] });
2637
+ var rV4 = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: badV4 });
2638
+ check("spf a/33 — v4 cidr-length >32 → permerror",
2639
+ rV4.result === "permerror" && /v4 cidr-length invalid/.test(rV4.explanation || ""));
2640
+
2641
+ // `a:` with an empty domain-spec resolves to no target domain → permerror.
2642
+ var noDomain = _txtOnly({ "s.example": [["v=spf1 a: -all"]] });
2643
+ var rND = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: noDomain });
2644
+ check("spf a: — empty domain-spec → no target domain → permerror",
2645
+ rND.result === "permerror" && /no target domain/.test(rND.explanation || ""));
2646
+
2647
+ // `a//64` — valid v6 cidr-length; an AAAA in the /64 of the connecting
2648
+ // IPv6 address matches → pass (exercises the v6-mask assignment arm).
2649
+ var v6ok = async function (host, type) {
2650
+ if (type === "TXT" && host === "s.example") return [["v=spf1 a//64 -all"]];
2651
+ if (type === "AAAA" && host === "s.example") return ["2001:db8::1"];
2652
+ return _enotfound();
2653
+ };
2654
+ var rV6 = await b.mail.spf.verify({ ip: "2001:db8::9", mailFrom: "a@s.example", dnsLookup: v6ok });
2655
+ check("spf a//64 — connecting IPv6 inside AAAA /64 → pass",
2656
+ rV6.result === "pass");
2657
+ }
2658
+
2659
+ // ---- SPF: §7.1 macro left-truncation to the 253-octet name ceiling ----
2660
+
2661
+ async function testSpfMacroLeftTruncation() {
2662
+ // RFC 7208 §7.1 — a macro expansion exceeding 253 octets is left-
2663
+ // truncated (leading labels discarded) until it fits. Drive it with an
2664
+ // oversized local-part (%{l}); the truncated target has no A record so
2665
+ // the exists misses and evaluation falls to -all.
2666
+ var longLocal = "x".repeat(300);
2667
+ var noDot = _txtOnly({ "s.example": [["v=spf1 exists:%{l} -all"]] });
2668
+ var rNoDot = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: longLocal + "@s.example", dnsLookup: noDot });
2669
+ check("spf exists:%{l} — >253-octet expansion (no dot) is truncated, evaluation completes → fail",
2670
+ rNoDot.result === "fail");
2671
+
2672
+ var withDot = _txtOnly({ "s.example": [["v=spf1 exists:%{l}.e -all"]] });
2673
+ var rDot = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: longLocal + "@s.example", dnsLookup: withDot });
2674
+ check("spf exists:%{l}.e — >253-octet expansion (dotted) left-truncated at label boundary → fail",
2675
+ rDot.result === "fail");
2676
+ }
2677
+
2678
+ // ---- SPF: exists mechanism crossing the §4.6.4 lookup ceiling ----
2679
+
2680
+ async function testSpfExistsLookupLimit() {
2681
+ // Ten `a:` mechanisms consume the 10-lookup budget (RFC 7208 §4.6.4);
2682
+ // the following `exists` increments past the ceiling → permerror.
2683
+ var rec = "v=spf1 a:h1 a:h2 a:h3 a:h4 a:h5 a:h6 a:h7 a:h8 a:h9 a:h10 exists:%{d} -all";
2684
+ var dns = async function (host, type) {
2685
+ if (type === "TXT" && host === "s.example") return [[rec]];
2686
+ if (type === "A") return ["10.0.0.1"]; // non-matching, so each a: misses
2687
+ return _enotfound();
2688
+ };
2689
+ var rv = await b.mail.spf.verify({ ip: "203.0.113.9", mailFrom: "a@s.example", dnsLookup: dns });
2690
+ check("spf exists — over the §4.6.4 DNS-lookup ceiling → permerror at exists",
2691
+ rv.result === "permerror" && /lookup limit exceeded/.test(rv.explanation || "") &&
2692
+ /at exists:/.test(rv.explanation || ""));
2693
+ }
2694
+
2695
+ // ---- DMARCbis: sp= tag value validation ----
2696
+
2697
+ function testDmarcParseBadSp() {
2698
+ var threw = null;
2699
+ try { b.mail.dmarc.parseRecord("v=DMARC1; p=none; sp=bogus"); }
2700
+ catch (e) { threw = e; }
2701
+ check("dmarc.parseRecord: sp= outside none|quarantine|reject → dmarcbis-bad-tag",
2702
+ threw && /dmarcbis-bad-tag/.test(threw.code || "") && /sp=/.test(threw.message || ""));
2703
+ }
2704
+
2705
+ // ---- DMARCbis §4.7: public-suffix-domain (psd=y) policy walk ----
2706
+
2707
+ async function testDmarcEvaluatePsdPolicy() {
2708
+ // Neither the From-domain nor its organizational domain publishes a
2709
+ // DMARC record, but the public suffix (gov.uk) does with psd=y — the
2710
+ // receiver continues the lookup at the suffix and applies that policy.
2711
+ var dns = async function (host) {
2712
+ if (host === "_dmarc.gov.uk") return [["v=DMARC1; p=reject; psd=y"]];
2713
+ var e = new Error("ENOTFOUND"); e.code = "ENOTFOUND"; throw e;
2714
+ };
2715
+ var rv = await b.mail.dmarc.evaluate({
2716
+ from: "alice@sub.example.gov.uk",
2717
+ spf: { result: "fail", domain: "attacker.example" },
2718
+ dkim: [],
2719
+ dnsLookup: dns,
2720
+ });
2721
+ check("dmarc.evaluate: psd=y suffix record applied when domain+org have none",
2722
+ rv.psdPolicyApplied === true &&
2723
+ rv.policyOriginDomain === "gov.uk" &&
2724
+ rv.result === "fail" &&
2725
+ rv.recommendedAction === "reject");
2726
+ }
2727
+
2728
+ // ---- ARC: header/body separator + folded-header unfolding ----
2729
+
2730
+ async function testArcNoBodyAndFolded() {
2731
+ // _splitHeaders (RFC 8617) requires a header/body separator; a message
2732
+ // without one is a config-time throw, not a silent none.
2733
+ var noBody = "ARC-Seal: i=1; a=rsa-sha256; cv=none; d=example.com; s=arc; b=AAAA\r\n" +
2734
+ "From: alice@example.com";
2735
+ var e1 = await _rejectedWith(b.mail.arc.verify(noBody));
2736
+ check("arc.verify: message with no header/body separator → arc-no-body",
2737
+ e1 && /arc-no-body/.test(e1.code || ""));
2738
+
2739
+ // RFC 5322 §2.2.3 — a folded ARC-Seal (continuation line beginning with
2740
+ // FWS) unfolds to a single logical header; the hop still parses as one.
2741
+ var folded =
2742
+ "ARC-Seal: i=1; a=rsa-sha256;\r\n cv=none; d=example.com; s=arc; b=AAAA\r\n" +
2743
+ "ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=arc; bh=AAAA; h=from; b=AAAA\r\n" +
2744
+ "ARC-Authentication-Results: i=1; example.com; spf=pass\r\n" +
2745
+ "From: alice@example.com\r\n\r\nbody\r\n";
2746
+ var rv = await b.mail.arc.verify(folded, { dnsLookup: async function () { return _enotfound(); } });
2747
+ check("arc.verify: folded ARC-Seal unfolds → single hop parsed",
2748
+ rv.hopCount === 1 && rv.chainStatus === "fail");
2749
+ }
2750
+
2751
+ // ---- ARC: arc.evaluate trustedSealers shape validation ----
2752
+
2753
+ async function testArcEvaluateTrustedSealersNotArray() {
2754
+ var eStr = await _rejectedWith(
2755
+ b.mail.arc.evaluate("From: x\r\n\r\nbody\r\n", { trustedSealers: "example.com" }));
2756
+ check("arc.evaluate: non-array trustedSealers → arc-bad-trusted-sealers",
2757
+ eStr && /arc-bad-trusted-sealers/.test(eStr.code || ""));
2758
+ var eMissing = await _rejectedWith(
2759
+ b.mail.arc.evaluate("From: x\r\n\r\nbody\r\n", {}));
2760
+ check("arc.evaluate: omitted trustedSealers → arc-bad-trusted-sealers",
2761
+ eMissing && /arc-bad-trusted-sealers/.test(eMissing.code || ""));
2762
+ }
2763
+
2764
+ // ---- authResults.emit: version tag header-injection guard (RFC 8601) ----
2765
+
2766
+ function testAuthResultsEmitVersionControlChars() {
2767
+ var eVer = _threw(function () {
2768
+ b.mail.authResults.emit({ authservId: "mx.a", version: "1\r\nInjected: 1", results: [] });
2769
+ });
2770
+ check("authResults.emit: CR/LF in version → ar-bad-version (no header injection)",
2771
+ eVer && /ar-bad-version/.test(eVer.code || ""));
2772
+ }
2773
+
2774
+ // ---- DMARC RUA aggregate: size cap + decompression + XML parse faults ----
2775
+
2776
+ function testDmarcAggregateSizeAndParseErrors() {
2777
+ // RFC 7489 §7.2.1.1 — a report exceeding the byte ceiling is refused
2778
+ // before any parse work (resource-exhaustion bound).
2779
+ var tooBig = Buffer.alloc(C.BYTES.mib(8) + 1);
2780
+ var eBig = _threw(function () { b.mail.dmarc.parseAggregateReport(tooBig); });
2781
+ check("dmarc.parseAggregateReport: over the byte cap → dmarc-rua-too-large",
2782
+ eBig && /dmarc-rua-too-large/.test(eBig.code || ""));
2783
+
2784
+ // gzip magic (0x1f 0x8b) present but the stream is malformed (not a
2785
+ // decompression bomb) → the distinct gunzip-failed diagnostic.
2786
+ var badGz = Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
2787
+ var eGz = _threw(function () { b.mail.dmarc.parseAggregateReport(badGz); });
2788
+ check("dmarc.parseAggregateReport: malformed gzip stream → dmarc-rua-gunzip-failed (not bomb)",
2789
+ eGz && /dmarc-rua-gunzip-failed/.test(eGz.code || ""));
2790
+
2791
+ // Well-formed uncompressed bytes that aren't XML → typed XML-parse error.
2792
+ var eXml = _threw(function () { b.mail.dmarc.parseAggregateReport("this is not xml at all"); });
2793
+ check("dmarc.parseAggregateReport: non-XML payload → dmarc-rua-bad-xml",
2794
+ eXml && /dmarc-rua-bad-xml/.test(eXml.code || ""));
2795
+ }
2796
+
2797
+ function testDmarcBuildTooManyRecords() {
2798
+ // RFC 7489 Appendix C — the aggregate builder caps records per report
2799
+ // (resource-exhaustion bound); over-cap input is a config-time throw.
2800
+ var eMany = _threw(function () {
2801
+ b.mail.dmarc.buildAggregateReport({
2802
+ reportMetadata: {}, policyPublished: {}, records: new Array(10001),
2803
+ });
2804
+ });
2805
+ check("dmarc.buildAggregateReport: over the per-report record cap → dmarc-rua-build-too-many-records",
2806
+ eMany && /dmarc-rua-build-too-many-records/.test(eMany.code || ""));
2807
+ }
2808
+
2809
+ // ---- DMARC RUF forensic: report-structure rejection + optional-field arms ----
2810
+
2811
+ function testDmarcForensicMoreErrorArms() {
2812
+ var base =
2813
+ "From: <postmaster@example.com>\r\n" +
2814
+ "MIME-Version: 1.0\r\n" +
2815
+ "Content-Type: multipart/report; report-type=feedback-report;\r\n" +
2816
+ "\tboundary=\"bnd\"\r\n" +
2817
+ "\r\n" +
2818
+ "--bnd\r\n" +
2819
+ "Content-Type: message/feedback-report\r\n" +
2820
+ "\r\n" +
2821
+ "Feedback-Type: auth-failure\r\n" +
2822
+ "User-Agent: R/1.0\r\n" +
2823
+ "Version: 1\r\n" +
2824
+ "Auth-Failure: dmarc\r\n" +
2825
+ "Reported-Domain: sender.example\r\n" +
2826
+ "\r\n" +
2827
+ "--bnd\r\n" +
2828
+ "Content-Type: message/rfc822\r\n" +
2829
+ "\r\n" +
2830
+ "From: Sender <noreply@sender.example>\r\n" +
2831
+ "Subject: hi\r\n" +
2832
+ "\r\n" +
2833
+ "body\r\n" +
2834
+ "--bnd--\r\n";
2835
+
2836
+ // A mismatched report-type is a valid feedback report but not a DMARC one.
2837
+ var rRt = b.mail.dmarc.parseForensicReport(base.replace("report-type=feedback-report", "report-type=abuse"));
2838
+ check("dmarc.parseForensicReport: report-type != feedback-report → dmarc-ruf-bad-report",
2839
+ rRt && rRt.ok === false && /dmarc-ruf-bad-report/.test(rRt.error.code || ""));
2840
+
2841
+ // multipart/report without a boundary parameter can't be split.
2842
+ var rNoBnd = b.mail.dmarc.parseForensicReport(
2843
+ "Content-Type: multipart/report; report-type=feedback-report\r\n\r\nbody\r\n");
2844
+ check("dmarc.parseForensicReport: multipart/report with no boundary → dmarc-ruf-bad-report",
2845
+ rNoBnd && rNoBnd.ok === false && /dmarc-ruf-bad-report/.test(rNoBnd.error.code || ""));
2846
+
2847
+ // Boundary present but the body has no parts.
2848
+ var rNoParts = b.mail.dmarc.parseForensicReport(
2849
+ "Content-Type: multipart/report; boundary=\"bnd\"\r\n\r\nnothing here\r\n");
2850
+ check("dmarc.parseForensicReport: multipart body with zero parts → dmarc-ruf-bad-report",
2851
+ rNoParts && rNoParts.ok === false && /dmarc-ruf-bad-report/.test(rNoParts.error.code || ""));
2852
+
2853
+ // Over the per-report part cap (CWE-400 bound).
2854
+ var manyBody = "";
2855
+ for (var mp = 0; mp < 70; mp += 1) manyBody += "--bnd\r\nContent-Type: text/plain\r\n\r\np" + mp + "\r\n";
2856
+ manyBody += "--bnd--\r\n";
2857
+ var rManyParts = b.mail.dmarc.parseForensicReport(
2858
+ "Content-Type: multipart/report; report-type=feedback-report; boundary=\"bnd\"\r\n\r\n" + manyBody);
2859
+ check("dmarc.parseForensicReport: over the part cap → dmarc-ruf-too-many-parts",
2860
+ rManyParts && rManyParts.ok === false && /dmarc-ruf-too-many-parts/.test(rManyParts.error.code || ""));
2861
+
2862
+ // Parts present but none is message/feedback-report.
2863
+ var noFb =
2864
+ "Content-Type: multipart/report; report-type=feedback-report; boundary=\"bnd\"\r\n\r\n" +
2865
+ "--bnd\r\nContent-Type: message/rfc822\r\n\r\nFrom: x@y\r\n\r\nb\r\n--bnd--\r\n";
2866
+ var rNoFb = b.mail.dmarc.parseForensicReport(noFb);
2867
+ check("dmarc.parseForensicReport: no message/feedback-report subpart → dmarc-ruf-no-feedback-report",
2868
+ rNoFb && rNoFb.ok === false && /dmarc-ruf-no-feedback-report/.test(rNoFb.error.code || ""));
2869
+
2870
+ // Feedback-Type absent (Auth-Failure present) → the generic required-field
2871
+ // arm (distinct from the Auth-Failure-specific one).
2872
+ var rMissFt = b.mail.dmarc.parseForensicReport(base.replace("Feedback-Type: auth-failure\r\n", ""));
2873
+ check("dmarc.parseForensicReport: missing Feedback-Type field → dmarc-ruf-missing-field",
2874
+ rMissFt && rMissFt.ok === false && /dmarc-ruf-missing-field/.test(rMissFt.error.code || ""));
2875
+
2876
+ // A headers-only reported message (text/rfc822-headers, no body) is
2877
+ // reassembled from its header bytes; Incidents normalizes to a number.
2878
+ var hdrsOnly =
2879
+ "Content-Type: multipart/report; report-type=feedback-report; boundary=\"bnd\"\r\n\r\n" +
2880
+ "--bnd\r\nContent-Type: message/feedback-report\r\n\r\n" +
2881
+ "Feedback-Type: auth-failure\r\nAuth-Failure: dmarc\r\nIncidents: 5\r\n\r\n" +
2882
+ "--bnd\r\nContent-Type: text/rfc822-headers\r\n\r\n" +
2883
+ "From: z@w\r\nSubject: s\r\n\r\n" +
2884
+ "--bnd--\r\n";
2885
+ var rHdrs = b.mail.dmarc.parseForensicReport(hdrsOnly);
2886
+ check("dmarc.parseForensicReport: text/rfc822-headers part reassembled + Incidents parsed",
2887
+ rHdrs && rHdrs.ok === true &&
2888
+ rHdrs.report.incidents === 5 &&
2889
+ rHdrs.report.reportedHeaders.length === 2);
2890
+
2891
+ // No reported-message part at all → reportedHeaders is an empty list.
2892
+ var noReported =
2893
+ "Content-Type: multipart/report; report-type=feedback-report; boundary=\"bnd\"\r\n\r\n" +
2894
+ "--bnd\r\nContent-Type: message/feedback-report\r\n\r\n" +
2895
+ "Feedback-Type: auth-failure\r\nAuth-Failure: dmarc\r\n\r\n--bnd--\r\n";
2896
+ var rNoRep = b.mail.dmarc.parseForensicReport(noReported);
2897
+ check("dmarc.parseForensicReport: no reported message → empty reportedHeaders, still ok",
2898
+ rNoRep && rNoRep.ok === true && rNoRep.report.reportedHeaders.length === 0);
2899
+
2900
+ // A text/rfc822-headers reported part with an EMPTY body (RFC 6591 §3.2
2901
+ // permits headers-only) is reassembled from its own header bytes rather
2902
+ // than dropped.
2903
+ var emptyBody =
2904
+ "Content-Type: multipart/report; report-type=feedback-report; boundary=\"bnd\"\r\n\r\n" +
2905
+ "--bnd\r\nContent-Type: message/feedback-report\r\n\r\n" +
2906
+ "Feedback-Type: auth-failure\r\nAuth-Failure: dmarc\r\n\r\n" +
2907
+ "--bnd\r\nContent-Type: text/rfc822-headers\r\n\r\n\r\n" +
2908
+ "--bnd--\r\n";
2909
+ var rEmpty = b.mail.dmarc.parseForensicReport(emptyBody);
2910
+ check("dmarc.parseForensicReport: empty-body text/rfc822-headers part is reassembled from header bytes",
2911
+ rEmpty && rEmpty.ok === true && rEmpty.report.reportedHeaders.length === 1);
2912
+ }
2913
+
2914
+ // ---- SPF: mx expansion crossing the lookup / void ceilings (§4.6.4) ----
2915
+
2916
+ async function testSpfMxExpansionLimits() {
2917
+ // Nine `a:` mechanisms exhaust the lookup budget; the following `mx`
2918
+ // resolves a host whose A-expansion increments past the ceiling.
2919
+ var recL = "v=spf1 a:h1 a:h2 a:h3 a:h4 a:h5 a:h6 a:h7 a:h8 a:h9 mx -all";
2920
+ var dnsL = async function (host, type) {
2921
+ if (type === "TXT" && host === "s.example") return [[recL]];
2922
+ if (type === "A" && /^h[0-9]/.test(host)) return ["10.0.0.1"];
2923
+ if (type === "MX" && host === "s.example") return [{ exchange: "mx1.s.example", preference: 10 }];
2924
+ if (type === "A" && host === "mx1.s.example") return ["10.0.0.2"];
2925
+ return _enotfound();
2926
+ };
2927
+ var rL = await b.mail.spf.verify({ ip: "203.0.113.9", mailFrom: "a@s.example", dnsLookup: dnsL });
2928
+ check("spf mx — A-expansion crossing the §4.6.4 lookup ceiling → permerror",
2929
+ rL.result === "permerror" && /during mx:s\.example expansion/.test(rL.explanation || ""));
2930
+
2931
+ // Three MX hosts that each lack an A record are three void lookups —
2932
+ // over the §4.6.4 void ceiling of 2 → permerror.
2933
+ var dnsV = async function (host, type) {
2934
+ if (type === "TXT" && host === "s.example") return [["v=spf1 mx -all"]];
2935
+ if (type === "MX" && host === "s.example") return ["m1.s.example", "m2.s.example", "m3.s.example"];
2936
+ if (type === "A") return []; // every MX host is a void lookup
2937
+ return _enotfound();
2938
+ };
2939
+ var rV = await b.mail.spf.verify({ ip: "203.0.113.9", mailFrom: "a@s.example", dnsLookup: dnsV });
2940
+ check("spf mx — over the §4.6.4 void-lookup ceiling during expansion → permerror",
2941
+ rV.result === "permerror" && /void-lookup limit exceeded/.test(rV.explanation || "") &&
2942
+ /mx expansion/.test(rV.explanation || ""));
2943
+ }
2944
+
2945
+ // ---- SPF: exists domain-spec that macro-expands to empty (§5.7) ----
2946
+
2947
+ async function testSpfExistsExpandsEmpty() {
2948
+ var dns = _txtOnly({ "s.example": [["v=spf1 exists:%{c} -all"]] });
2949
+ var rv = await b.mail.spf.verify({ ip: "203.0.113.9", mailFrom: "a@s.example", dnsLookup: dns });
2950
+ check("spf exists:%{c} — domain-spec expands to empty → permerror (RFC 7208 §5.7)",
2951
+ rv.result === "permerror" && /expanded to an empty domain/.test(rv.explanation || ""));
2952
+ }
2953
+
2954
+ // ---- SPF: redirect= recursion depth ceiling (§6.1) ----
2955
+
2956
+ async function testSpfRedirectDepthLimit() {
2957
+ // A chain of redirect= modifiers that never terminates trips the
2958
+ // recursion bound rather than spinning.
2959
+ var dns = async function (host, type) {
2960
+ if (type === "TXT") {
2961
+ var m = /^d(\d+)\.ex$/.exec(host);
2962
+ if (m) return [["v=spf1 redirect=d" + (parseInt(m[1], 10) + 1) + ".ex"]];
2963
+ }
2964
+ return _enotfound();
2965
+ };
2966
+ var rv = await b.mail.spf.verify({ ip: "203.0.113.9", mailFrom: "a@d0.ex", dnsLookup: dns });
2967
+ check("spf redirect= — unbounded redirect chain trips the §6.1 recursion limit → permerror",
2968
+ rv.result === "permerror" && /redirect= recursion limit exceeded/.test(rv.explanation || ""));
2969
+ }
2970
+
2971
+ // ---- ARC: AS key TXT returned as a non-array operator answer ----
2972
+
2973
+ async function testArcKeyNonArrayTxt() {
2974
+ // A dnsLookup that returns the key record as a bare string (not the
2975
+ // documented array-of-string-arrays shape) is still parsed for its
2976
+ // tags; the malformed key material then fails the parse → permerror.
2977
+ var from = "From: alice@example.com\r\nTo: bob@example.com\r\n\r\nbody\r\n";
2978
+ var fullHop =
2979
+ "ARC-Seal: i=1; a=rsa-sha256; cv=none; d=example.com; s=arc; b=AAAA\r\n" +
2980
+ "ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=arc; bh=AAAA; h=from; b=AAAA\r\n" +
2981
+ "ARC-Authentication-Results: i=1; example.com; spf=pass\r\n" + from;
2982
+ var dns = async function (q) {
2983
+ if (q === "arc._domainkey.example.com") return "v=DKIM1; k=rsa; p=AAAABBBB";
2984
+ return _enotfound();
2985
+ };
2986
+ var rv = await b.mail.arc.verify(fullHop, { dnsLookup: dns });
2987
+ check("arc.verify: key TXT as bare string → tags parsed, bad key material → permerror",
2988
+ rv.chainStatus === "fail" &&
2989
+ (rv.hops[0] || {}).asResult === "permerror" &&
2990
+ /key parse failed/.test(((rv.hops[0] || {}).asErrors || []).join(" ; ")));
2991
+ }
2992
+
2993
+ // ---- iprev (RFC 8601 §3): forward-confirm decision via operator dnsLookup ----
2994
+ //
2995
+ // Every other primitive in mail-auth exposes an operator dnsLookup so the
2996
+ // verdict logic is unit-testable offline; iprev now does too (the PTR query
2997
+ // receives the reverse-arpa qname, the forward query the PTR name). These
2998
+ // drive the fcrdns pass/fail, PTR-shape rejection, and transient-fault arms
2999
+ // that were previously reachable only over the live network.
3000
+
3001
+ // Build an iprev dnsLookup fake from a { ptr: [...], forward: {host: [...]} }
3002
+ // spec. `errByCode` overrides a query to REJECT with the given DNS error code.
3003
+ function _iprevDns(spec) {
3004
+ return async function (qname, type) {
3005
+ if (type === "PTR") {
3006
+ if (spec.ptrError) { var pe = new Error(spec.ptrError); pe.code = spec.ptrError; throw pe; }
3007
+ return spec.ptr || [];
3008
+ }
3009
+ if (type === "A" || type === "AAAA") {
3010
+ var host = qname.replace(/\.$/, "");
3011
+ if (spec.forwardError) { var fe = new Error(spec.forwardError); fe.code = spec.forwardError; throw fe; }
3012
+ var fwd = (spec.forward || {})[host];
3013
+ if (fwd === undefined) { var e = new Error("ENODATA"); e.code = "ENODATA"; throw e; }
3014
+ return fwd;
3015
+ }
3016
+ var e2 = new Error("ENOTFOUND"); e2.code = "ENOTFOUND"; throw e2;
3017
+ };
3018
+ }
3019
+
3020
+ async function testIprevForwardConfirmedPass() {
3021
+ // IPv4 FCrDNS: PTR resolves to a name whose forward A set contains the
3022
+ // connecting IP → pass, fcrdns true. Also asserts the operator PTR query
3023
+ // received the reverse-arpa qname (exercises _ipToReverseArpa).
3024
+ var seenPtrQname = null;
3025
+ var dns = async function (qname, type) {
3026
+ if (type === "PTR") { seenPtrQname = qname; return ["mail.sender.example."]; }
3027
+ if (type === "A" && qname.replace(/\.$/, "") === "mail.sender.example") return ["203.0.113.5"];
3028
+ var e = new Error("ENODATA"); e.code = "ENODATA"; throw e;
3029
+ };
3030
+ var rv = await b.mail.iprev.verify("203.0.113.5", { dnsLookup: dns });
3031
+ check("iprev.verify: forward-confirmed IPv4 → pass + fcrdns true",
3032
+ rv.result === "pass" && rv.fcrdns === true &&
3033
+ rv.ptr === "mail.sender.example" && rv.forward.indexOf("203.0.113.5") !== -1);
3034
+ check("iprev.verify: PTR query used the reverse-arpa qname (RFC 8601 §3)",
3035
+ seenPtrQname === "5.113.0.203.in-addr.arpa");
3036
+ }
3037
+
3038
+ async function testIprevForwardMismatchFail() {
3039
+ // PTR forward-resolves to a set that does NOT include the connecting IP —
3040
+ // a spoofed reverse zone. RFC 8601 §3: fail, not pass.
3041
+ var dns = _iprevDns({ ptr: ["evil.example."], forward: { "evil.example": ["198.51.100.9"] } });
3042
+ var rv = await b.mail.iprev.verify("203.0.113.5", { dnsLookup: dns });
3043
+ check("iprev.verify: PTR forward-set omits the connecting IP → fail (not pass)",
3044
+ rv.result === "fail" && rv.fcrdns === false && rv.ptr === "evil.example");
3045
+ }
3046
+
3047
+ async function testIprevNoPtrFail() {
3048
+ // Operator PTR lookup returns an empty answer → no PTR → fail.
3049
+ var dns = _iprevDns({ ptr: [] });
3050
+ var rv = await b.mail.iprev.verify("203.0.113.5", { dnsLookup: dns });
3051
+ check("iprev.verify: empty PTR answer set → fail",
3052
+ rv.result === "fail" && rv.fcrdns === false);
3053
+ }
3054
+
3055
+ async function testIprevNoForwardRecordFail() {
3056
+ // PTR resolves but the name has no forward record (ENODATA) → fail.
3057
+ var dns = _iprevDns({ ptr: ["host.example."], forward: {} });
3058
+ var rv = await b.mail.iprev.verify("203.0.113.5", { dnsLookup: dns });
3059
+ check("iprev.verify: PTR present but no forward record → fail",
3060
+ rv.result === "fail" && rv.ptr === "host.example");
3061
+ }
3062
+
3063
+ async function testIprevReverseTransientTemperror() {
3064
+ // A transient reverse-lookup fault (SERVFAIL) → temperror (a verdict).
3065
+ var dns = _iprevDns({ ptrError: "ESERVFAIL" });
3066
+ var rv = await b.mail.iprev.verify("203.0.113.5", { dnsLookup: dns });
3067
+ check("iprev.verify: transient reverse (SERVFAIL) → temperror verdict",
3068
+ rv.result === "temperror" && rv.fcrdns === false);
3069
+ }
3070
+
3071
+ async function testIprevHostilePtrShapePermerror() {
3072
+ // RFC 8601 §3 — an attacker controlling the reverse zone can publish a PTR
3073
+ // whose rdata is arbitrary bytes (e.g. `evil<script>`). It MUST be refused
3074
+ // as permerror BEFORE the name reaches any downstream (audit / A-R) sink,
3075
+ // and the forward lookup must not even be attempted.
3076
+ var forwardQueried = false;
3077
+ var dns = async function (qname, type) {
3078
+ if (type === "PTR") return ["evil<script>.example."];
3079
+ if (type === "A") { forwardQueried = true; return ["203.0.113.5"]; }
3080
+ var e = new Error("ENODATA"); e.code = "ENODATA"; throw e;
3081
+ };
3082
+ var rv = await b.mail.iprev.verify("203.0.113.5", { dnsLookup: dns });
3083
+ check("iprev.verify: hostile PTR rdata → permerror (not a valid DNS name shape)",
3084
+ rv.result === "permerror" && rv.fcrdns === false &&
3085
+ /not a valid DNS name shape/.test(rv.explanation || ""));
3086
+ check("iprev.verify: hostile PTR short-circuits before the forward query",
3087
+ forwardQueried === false);
3088
+ }
3089
+
3090
+ async function testIprevMultiplePtrPicksFirst() {
3091
+ // RFC 8601 §3 — with multiple PTRs the receiver picks one (the first) and
3092
+ // continues the forward-confirm on it.
3093
+ var dns = _iprevDns({
3094
+ ptr: ["first.example.", "second.example."],
3095
+ forward: { "first.example": ["203.0.113.5"] },
3096
+ });
3097
+ var rv = await b.mail.iprev.verify("203.0.113.5", { dnsLookup: dns });
3098
+ check("iprev.verify: multiple PTRs → picks the first + forward-confirms it",
3099
+ rv.result === "pass" && rv.ptr === "first.example");
3100
+ }
3101
+
3102
+ async function testIprevOptsValidation() {
3103
+ // Config-time entry point — an unknown opt key is an operator typo and
3104
+ // THROWS (three-tier: entry-point throw), not a silent ignore.
3105
+ var threw = null;
3106
+ try { await b.mail.iprev.verify("203.0.113.5", { bogus: 1 }); }
3107
+ catch (e) { threw = e; }
3108
+ check("iprev.verify: unknown opt key → config-time throw", threw !== null);
3109
+ }
3110
+
3111
+ // RED — RFC 8601 §3: the forward-lookup catch must RETURN a temperror verdict
3112
+ // on an un-enumerated transient DNS code, mirroring the reverse-lookup catch
3113
+ // (which already does) and the primitive's own stated intent ("propagate as
3114
+ // temperror"). Pre-fix it THREW a MailAuthError for any code that wasn't
3115
+ // ENODATA/ENOTFOUND/ETIMEOUT/ESERVFAIL, so a caller of the documented
3116
+ // verdict-returning API got an exception on e.g. an EREFUSED forward fault.
3117
+ async function testIprevForwardUnknownCodeIsTemperrorNotThrow() {
3118
+ var dns = _iprevDns({ ptr: ["host.example."], forwardError: "EREFUSED" });
3119
+ var threw = null, rv = null;
3120
+ try { rv = await b.mail.iprev.verify("203.0.113.5", { dnsLookup: dns }); }
3121
+ catch (e) { threw = e; }
3122
+ check("iprev.verify: forward EREFUSED → temperror VERDICT (never a throw)",
3123
+ threw === null && rv !== null && rv.result === "temperror" && rv.ptr === "host.example");
3124
+ }
3125
+
3126
+ // RED — RFC 8601 §3 (IPv6 FCrDNS): the connecting IPv6 literal and the AAAA
3127
+ // forward record can carry different-but-equivalent textual forms (compressed
3128
+ // `2001:db8::1` vs expanded `2001:0db8:0:0:0:0:0:1`). A raw lowercased string
3129
+ // compare misses the match and marks a legitimately forward-confirmed IPv6
3130
+ // sender as fail. The compare must canonicalize both addresses.
3131
+ async function testIprevIpv6ForwardConfirmCanonicalizes() {
3132
+ var dns = async function (qname, type) {
3133
+ if (type === "PTR") return ["v6.sender.example."];
3134
+ // AAAA answer in fully-expanded form — same address, different text.
3135
+ if (type === "AAAA" && qname.replace(/\.$/, "") === "v6.sender.example") {
3136
+ return ["2001:0db8:0000:0000:0000:0000:0000:0001"];
3137
+ }
3138
+ var e = new Error("ENODATA"); e.code = "ENODATA"; throw e;
3139
+ };
3140
+ var rv = await b.mail.iprev.verify("2001:db8::1", { dnsLookup: dns });
3141
+ check("iprev.verify: IPv6 forward-confirm canonicalizes both forms → pass",
3142
+ rv.result === "pass" && rv.fcrdns === true);
3143
+ }
3144
+
3145
+ // ---- SPF: malformed CIDR masks in ip4 / ip6 mechanisms never match ----
3146
+
3147
+ async function testSpfMalformedCidrMasksNoMatch() {
3148
+ // ip4 with an out-of-range prefix (/40 > 32) MUST NOT match — the
3149
+ // connecting IP falls through to -all → fail. A prefix parse that
3150
+ // silently clamped would over-authorize.
3151
+ var d40 = _txtOnly({ "s.example": [["v=spf1 ip4:192.0.2.0/40 -all"]] });
3152
+ var r40 = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: d40 });
3153
+ check("spf ip4:.../40 (mask > 32) → no match → fail (-all)", r40.result === "fail");
3154
+
3155
+ // ip4 with an unparsable network literal (octet > 255) → no match.
3156
+ var dBadNet = _txtOnly({ "s.example": [["v=spf1 ip4:300.1.1.1/24 -all"]] });
3157
+ var rBadNet = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: dBadNet });
3158
+ check("spf ip4 with an unparsable network literal → no match → fail", rBadNet.result === "fail");
3159
+
3160
+ // ip4 with no prefix at all (single-host) — exact match branch.
3161
+ var dHost = _txtOnly({ "s.example": [["v=spf1 ip4:192.0.2.5 -all"]] });
3162
+ var rHost = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: dHost });
3163
+ check("spf ip4 single-host (no /prefix) → exact match → pass", rHost.result === "pass");
3164
+
3165
+ // ip6 with an out-of-range prefix (/200 > 128) MUST NOT match.
3166
+ var d200 = _txtOnly({ "s.example": [["v=spf1 ip6:2001:db8::/200 -all"]] });
3167
+ var r200 = await b.mail.spf.verify({ ip: "2001:db8::5", mailFrom: "a@s.example", dnsLookup: d200 });
3168
+ check("spf ip6:.../200 (mask > 128) → no match → fail (-all)", r200.result === "fail");
3169
+
3170
+ // ip6 with an unparsable network literal (non-hex group) → no match.
3171
+ var dBad6 = _txtOnly({ "s.example": [["v=spf1 ip6:zzzz::/64 -all"]] });
3172
+ var rBad6 = await b.mail.spf.verify({ ip: "2001:db8::5", mailFrom: "a@s.example", dnsLookup: dBad6 });
3173
+ check("spf ip6 with an unparsable network literal → no match → fail", rBad6.result === "fail");
3174
+
3175
+ // ip6 single-host (no /prefix) exact match branch.
3176
+ var dHost6 = _txtOnly({ "s.example": [["v=spf1 ip6:2001:db8::5 -all"]] });
3177
+ var rHost6 = await b.mail.spf.verify({ ip: "2001:db8::5", mailFrom: "a@s.example", dnsLookup: dHost6 });
3178
+ check("spf ip6 single-host (no /prefix) → exact match → pass", rHost6.result === "pass");
3179
+ }
3180
+
3181
+ // ---- SPF: TXT records present but none is a v=spf1 record → none ----
3182
+
3183
+ async function testSpfTxtPresentButNoSpf1IsNone() {
3184
+ // The domain publishes TXT records (e.g. a verification token) but no
3185
+ // v=spf1 record. RFC 7208 §4.6 — the check result is "none", not a
3186
+ // fail/permerror; the receiver has no SPF policy to apply.
3187
+ var dns = _txtOnly({ "s.example": [
3188
+ ["google-site-verification=abc123"],
3189
+ ["v=spf2.0/pra ~all"],
3190
+ ] });
3191
+ var rv = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: dns });
3192
+ check("spf.verify: TXT present but no v=spf1 record → none", rv.result === "none");
3193
+ }
3194
+
3195
+ // ---- SPF: %{h} HELO macro expands into an exists: domain-spec ----
3196
+
3197
+ async function testSpfHeloMacroExpansion() {
3198
+ // RFC 7208 §7.2 — %{h} is the HELO/EHLO domain. An exists: policy using
3199
+ // it must A-query the expanded name that embeds the HELO identity.
3200
+ var queried = [];
3201
+ var dns = async function (host, type) {
3202
+ if (type === "TXT" && host === "s.example") return [["v=spf1 exists:%{h}.probe.example -all"]];
3203
+ if (type === "A") { queried.push(host); return ["127.0.0.2"]; }
3204
+ var e = new Error("ENOTFOUND"); e.code = "ENOTFOUND"; throw e;
3205
+ };
3206
+ var rv = await b.mail.spf.verify({
3207
+ ip: "192.0.2.9", mailFrom: "alice@s.example", helo: "relay.helo.example", dnsLookup: dns,
3208
+ });
3209
+ check("spf %{h} HELO macro → A-query embeds the HELO domain → pass",
3210
+ rv.result === "pass" && queried.indexOf("relay.helo.example.probe.example") !== -1);
3211
+ }
3212
+
3213
+ // ---- DMARC: alignment against a missing / uncanonicalizable auth domain ----
3214
+
3215
+ async function testDmarcAlignmentMissingAndGarbageAuthDomain() {
3216
+ var dns = _txtOnly({ "_dmarc.example.com": [["v=DMARC1; p=reject; aspf=r; adkim=r"]] });
3217
+
3218
+ // SPF passed but carries no domain (spf.domain absent). The alignment
3219
+ // check must fail closed on a null auth domain — not throw, not align.
3220
+ var rvNoDomain = await b.mail.dmarc.evaluate({
3221
+ from: "alice@example.com",
3222
+ spf: { result: "pass" }, // note: no `domain`
3223
+ dkim: [],
3224
+ dnsLookup: dns,
3225
+ });
3226
+ check("dmarc.evaluate: SPF pass with no auth domain → not aligned → fail",
3227
+ rvNoDomain.result === "fail" && rvNoDomain.alignment.spf === false);
3228
+
3229
+ // DKIM passed but its d= canonicalizes to the empty string (leading-dot
3230
+ // hostname) — alignment must reject it rather than treat "" as a match.
3231
+ var rvGarbage = await b.mail.dmarc.evaluate({
3232
+ from: "alice@example.com",
3233
+ spf: { result: "fail" },
3234
+ dkim: [{ result: "pass", domain: ".example.com" }],
3235
+ dnsLookup: dns,
3236
+ });
3237
+ check("dmarc.evaluate: DKIM pass whose domain canonicalizes to empty → not aligned → fail",
3238
+ rvGarbage.result === "fail" && rvGarbage.alignment.dkim === false &&
3239
+ rvGarbage.recommendedAction === "reject");
3240
+ }
3241
+
3242
+ // ---- DMARC: pct out of range clamps to 100 (full application) ----
3243
+
3244
+ async function testDmarcPctOutOfRangeClampsTo100() {
3245
+ // A published pct greater than 100 is nonsensical; RFC 7489 §6.3 bounds
3246
+ // it to [0,100]. An out-of-range value clamps to 100 → the policy is
3247
+ // applied to every failing message (no sampling downgrade).
3248
+ var dns = _txtOnly({ "_dmarc.example.com": [["v=DMARC1; p=quarantine; pct=250; aspf=r; adkim=r"]] });
3249
+ var rv = await b.mail.dmarc.evaluate({
3250
+ from: "alice@example.com",
3251
+ spf: { result: "fail" },
3252
+ dkim: [],
3253
+ dnsLookup: dns,
3254
+ });
3255
+ check("dmarc.evaluate: pct > 100 clamps to 100 → unaligned fail applies policy (quarantine)",
3256
+ rv.result === "fail" && rv.recommendedAction === "quarantine");
3257
+ }
3258
+
3259
+ // ---- DMARC: no dkim argument at all → empty dkim-results list ----
3260
+
3261
+ async function testDmarcEvaluateNoDkimArgument() {
3262
+ // Omitting the dkim opt entirely (not even an empty array) must not
3263
+ // crash — the evaluator treats it as zero DKIM results and can still
3264
+ // pass on an aligned SPF authenticator.
3265
+ var dns = _txtOnly({ "_dmarc.example.com": [["v=DMARC1; p=reject; aspf=r; adkim=r"]] });
3266
+ var rv = await b.mail.dmarc.evaluate({
3267
+ from: "alice@example.com",
3268
+ spf: { result: "pass", domain: "example.com" },
3269
+ dnsLookup: dns,
3270
+ });
3271
+ check("dmarc.evaluate: dkim opt omitted → treated as [] → aligned SPF still passes",
3272
+ rv.result === "pass" && rv.alignment.spf === true && rv.alignment.dkim === false);
3273
+ }
3274
+
3275
+ // ---- DMARC: domainExists callback that throws is treated as existing ----
3276
+
3277
+ async function testDmarcDomainExistsCallbackThrows() {
3278
+ // DMARCbis §4.8 np= path — when the operator's domainExists callback
3279
+ // throws, the evaluator must fail SAFE by treating the domain as
3280
+ // existing (np= NOT applied), never crash the evaluation.
3281
+ var dns = async function (host) {
3282
+ if (host === "_dmarc.example.com") return [["v=DMARC1; p=quarantine; np=reject; aspf=r; adkim=r"]];
3283
+ var e = new Error("ENOTFOUND"); e.code = "ENOTFOUND"; throw e;
3284
+ };
3285
+ var threw = null, rv = null;
3286
+ try {
3287
+ rv = await b.mail.dmarc.evaluate({
3288
+ from: "user@sub.example.com", // subdomain → org-domain policy applies
3289
+ spf: { result: "fail" },
3290
+ dkim: [],
3291
+ dnsLookup: dns,
3292
+ domainExists: function () { throw new Error("resolver boom"); },
3293
+ });
3294
+ } catch (e) { threw = e; }
3295
+ check("dmarc.evaluate: throwing domainExists → no crash, org policy applied",
3296
+ threw === null && rv !== null && rv.orgDomainPolicyApplied === true);
3297
+ check("dmarc.evaluate: throwing domainExists → np= NOT applied (fails safe to existing)",
3298
+ rv && rv.npPolicyApplied === false);
3299
+ }
3300
+
3301
+ // ---- ARC: ARC-Seal t=/x= time faults (not just AMS) ----
3302
+
3303
+ async function testArcSealTimeFaults() {
3304
+ var now = Math.floor(Date.now() / 1000);
3305
+ var keyDns = async function (qname) {
3306
+ if (qname === "arc._domainkey.example.com") {
3307
+ var nc = require("crypto");
3308
+ var pair = nc.generateKeyPairSync("rsa", { modulusLength: 2048 });
3309
+ return [["v=DKIM1; k=rsa; p=" +
3310
+ pair.publicKey.export({ type: "spki", format: "der" }).toString("base64")]];
3311
+ }
3312
+ var e = new Error("ENOTFOUND"); e.code = "ENOTFOUND"; throw e;
3313
+ };
3314
+
3315
+ // ARC-Seal with a future t= — the seal's signing timestamp is ahead of
3316
+ // now, a RFC 8617 §5.2 time fault that fails the hop closed.
3317
+ var futureSeal =
3318
+ "ARC-Seal: i=1; a=rsa-sha256; cv=none; d=example.com; s=arc; t=" + (now + 999999) + "; b=AAAA\r\n" +
3319
+ "ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=arc; bh=AAAA; h=from; b=AAAA\r\n" +
3320
+ "ARC-Authentication-Results: i=1; example.com; spf=pass\r\n" +
3321
+ "From: alice@example.com\r\nTo: bob@example.com\r\n\r\nbody\r\n";
3322
+ var rvT = await b.mail.arc.verify(futureSeal, { dnsLookup: keyDns, clockSkewMs: 1000 });
3323
+ var tErrs = ((rvT.hops[0] && rvT.hops[0].asErrors) || []).join(" ; ");
3324
+ check("arc.verify: ARC-Seal future t= → as-t-future time fault (hop fails closed)",
3325
+ rvT.chainStatus === "fail" && /as-t-future/.test(tErrs));
3326
+
3327
+ // ARC-Seal with an expired x= — the seal's expiry is in the past.
3328
+ var expiredSeal =
3329
+ "ARC-Seal: i=1; a=rsa-sha256; cv=none; d=example.com; s=arc; x=" + (now - 999999) + "; b=AAAA\r\n" +
3330
+ "ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=arc; bh=AAAA; h=from; b=AAAA\r\n" +
3331
+ "ARC-Authentication-Results: i=1; example.com; spf=pass\r\n" +
3332
+ "From: alice@example.com\r\nTo: bob@example.com\r\n\r\nbody\r\n";
3333
+ var rvX = await b.mail.arc.verify(expiredSeal, { dnsLookup: keyDns });
3334
+ var xErrs = ((rvX.hops[0] && rvX.hops[0].asErrors) || []).join(" ; ");
3335
+ check("arc.verify: ARC-Seal expired x= → as-x-expired time fault (hop fails closed)",
3336
+ rvX.chainStatus === "fail" && /as-x-expired/.test(xErrs));
3337
+ }
3338
+
3339
+ // ---- ARC: key TXT lookup returning ENODATA → permerror (not temperror) ----
3340
+
3341
+ async function testArcKeyEnodataIsPermerror() {
3342
+ // A definitive "no such key record" answer (ENODATA) on the signing-key
3343
+ // lookup is a permanent failure per RFC 8617 §5.1.1, distinct from a
3344
+ // transient (temperror) resolver fault.
3345
+ var dns = async function () {
3346
+ var e = new Error("ENODATA"); e.code = "ENODATA"; throw e;
3347
+ };
3348
+ var msg =
3349
+ "ARC-Seal: i=1; a=rsa-sha256; cv=none; d=example.com; s=arc; b=AAAA\r\n" +
3350
+ "ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=arc; bh=AAAA; h=from; b=AAAA\r\n" +
3351
+ "ARC-Authentication-Results: i=1; example.com; spf=pass\r\n" +
3352
+ "From: alice@example.com\r\nTo: bob@example.com\r\n\r\nbody\r\n";
3353
+ var rv = await b.mail.arc.verify(msg, { dnsLookup: dns });
3354
+ var errs = ((rv.hops[0] && rv.hops[0].asErrors) || [])
3355
+ .concat((rv.hops[0] && rv.hops[0].amsErrors) || []).join(" ; ");
3356
+ check("arc.verify: signing-key ENODATA → permerror hop verdict (fail chain)",
3357
+ rv.chainStatus === "fail" && /key lookup failed/.test(errs));
3358
+ }
3359
+
3360
+ // ---- iprev: PTR names with oversize labels / oversize total length ----
3361
+
3362
+ async function testIprevOversizePtrShapePermerror() {
3363
+ // A PTR whose single label exceeds 63 octets is not a valid DNS name
3364
+ // (RFC 1035 §2.3.4) — refuse as permerror before the forward query.
3365
+ var longLabel = "a";
3366
+ for (var i = 0; i < 64; i += 1) longLabel += "a"; // 65-octet label
3367
+ var forwardQueried = false;
3368
+ var dnsLabel = async function (qname, type) {
3369
+ if (type === "PTR") return [longLabel + ".example."];
3370
+ if (type === "A") { forwardQueried = true; return ["203.0.113.5"]; }
3371
+ var e = new Error("ENODATA"); e.code = "ENODATA"; throw e;
3372
+ };
3373
+ var rvLabel = await b.mail.iprev.verify("203.0.113.5", { dnsLookup: dnsLabel });
3374
+ check("iprev.verify: PTR label > 63 octets → permerror, forward not attempted",
3375
+ rvLabel.result === "permerror" && forwardQueried === false);
3376
+
3377
+ // A PTR whose total length exceeds 253 octets is likewise refused.
3378
+ var longName = "";
3379
+ for (var j = 0; j < 6; j += 1) longName += "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz."; // 6 * 51 = 306
3380
+ var dnsName = async function (qname, type) {
3381
+ if (type === "PTR") return [longName + "example."];
3382
+ var e = new Error("ENODATA"); e.code = "ENODATA"; throw e;
3383
+ };
3384
+ var rvName = await b.mail.iprev.verify("203.0.113.5", { dnsLookup: dnsName });
3385
+ check("iprev.verify: PTR total length > 253 octets → permerror",
3386
+ rvName.result === "permerror");
3387
+ }
3388
+
3389
+ // ---- iprev: forward lookup rejecting with a code-less error → temperror ----
3390
+
3391
+ async function testIprevForwardCodelessErrorIsTemperror() {
3392
+ // The forward-confirm lookup rejects with a plain Error (no `.code`).
3393
+ // The verifier must still RETURN a temperror verdict (never throw), and
3394
+ // surface the error message in the explanation.
3395
+ var dns = async function (qname, type) {
3396
+ if (type === "PTR") return ["host.example."];
3397
+ throw new Error("upstream boom"); // no .code on the forward fault
3398
+ };
3399
+ var threw = null, rv = null;
3400
+ try { rv = await b.mail.iprev.verify("203.0.113.5", { dnsLookup: dns }); }
3401
+ catch (e) { threw = e; }
3402
+ check("iprev.verify: code-less forward fault → temperror VERDICT (no throw)",
3403
+ threw === null && rv !== null && rv.result === "temperror" &&
3404
+ /boom/.test(rv.explanation || ""));
3405
+ }
3406
+
3407
+ // ---- DMARC aggregate report: minimal / sparse shapes fall back cleanly ----
3408
+
3409
+ function testDmarcAggregateMinimalShape() {
3410
+ // A pre-parsed <feedback> with a single empty record and no metadata /
3411
+ // policy blocks must shape to all-null leaves rather than crash — every
3412
+ // absent field maps to null / [] and the totals are zero.
3413
+ var shaped = b.mail.dmarc.parseAggregateReport({ feedback: { record: [{}] } });
3414
+ check("dmarc.parseAggregateReport: sparse report shapes metadata leaves to null",
3415
+ shaped.reportMetadata.orgName === null &&
3416
+ shaped.reportMetadata.dateRange.begin === null &&
3417
+ shaped.policyPublished.domain === null && shaped.policyPublished.pct === null);
3418
+ check("dmarc.parseAggregateReport: empty record shapes to null leaves + zero totals",
3419
+ shaped.records.length === 1 &&
3420
+ shaped.records[0].sourceIp === null && shaped.records[0].count === null &&
3421
+ shaped.records[0].dispositions.disposition === null &&
3422
+ shaped.records[0].authResults.dkim.length === 0 &&
3423
+ shaped.totals.messages === 0);
3424
+ }
3425
+
3426
+ // ---- DMARC aggregate report builder: minimal shape serializes valid XML ----
3427
+
3428
+ function testDmarcAggregateBuildMinimalShape() {
3429
+ // The builder must accept a minimal shape (empty metadata / policy, one
3430
+ // record with no dispositions / identifiers / auth_results) and still
3431
+ // emit a well-formed <feedback> document.
3432
+ var xml = b.mail.dmarc.buildAggregateReport({
3433
+ reportMetadata: {},
3434
+ policyPublished: {},
3435
+ records: [{}],
3436
+ });
3437
+ check("dmarc.buildAggregateReport: minimal shape → well-formed <feedback> XML",
3438
+ typeof xml === "string" &&
3439
+ xml.indexOf("<feedback>") !== -1 &&
3440
+ xml.indexOf("<record>") !== -1 &&
3441
+ xml.indexOf("<auth_results>") !== -1);
3442
+ // And it round-trips back through the parser without throwing.
3443
+ var reparsed = b.mail.dmarc.parseAggregateReport(xml);
3444
+ check("dmarc.buildAggregateReport: minimal build round-trips through the parser",
3445
+ reparsed && Array.isArray(reparsed.records) && reparsed.records.length === 1);
3446
+ }
3447
+
3448
+ // ---- inbound.verify: DKIM tuning opts are threaded to the DKIM verifier ----
3449
+
3450
+ async function testInboundVerifyDkimTuningOpts() {
3451
+ // clockSkewMs / maxSignatures / minRsaBits are forwarded to the DKIM
3452
+ // verifier when supplied. An unsigned message still verifies (dkim
3453
+ // none) and the call must not crash while threading the opts.
3454
+ var dns = async function () {
3455
+ var e = new Error("ENOTFOUND"); e.code = "ENOTFOUND"; throw e;
3456
+ };
3457
+ var msg = "From: alice@example.com\r\nTo: bob@example.com\r\n\r\nhello\r\n";
3458
+ var v = await b.mail.inbound.verify({
3459
+ ip: "192.0.2.1", helo: "mail.example", message: msg,
3460
+ dnsLookup: dns, clockSkewMs: 60000, maxSignatures: 5, minRsaBits: 1024,
3461
+ });
3462
+ check("inbound.verify: DKIM tuning opts threaded → unsigned message dkim none, no crash",
3463
+ Array.isArray(v.dkim) && v.dkim.length === 1 && v.dkim[0].result === "none");
3464
+ }
3465
+
1761
3466
  async function run() {
1762
3467
  testByteCapMultibyte();
1763
3468
  testSurface();
@@ -1816,6 +3521,16 @@ async function run() {
1816
3521
  await testIprevSurface();
1817
3522
  await testIprevPermerror();
1818
3523
  await testIprevValidIpShape();
3524
+ await testIprevForwardConfirmedPass();
3525
+ await testIprevForwardMismatchFail();
3526
+ await testIprevNoPtrFail();
3527
+ await testIprevNoForwardRecordFail();
3528
+ await testIprevReverseTransientTemperror();
3529
+ await testIprevHostilePtrShapePermerror();
3530
+ await testIprevMultiplePtrPicksFirst();
3531
+ await testIprevOptsValidation();
3532
+ await testIprevForwardUnknownCodeIsTemperrorNotThrow();
3533
+ await testIprevIpv6ForwardConfirmCanonicalizes();
1819
3534
  // Audit 2026-05-15 — MAIL-9/10/25/39/50/56/58/66
1820
3535
  await testSpfRedirectModifier();
1821
3536
  await testSpfVoidLookupLimit();
@@ -1834,6 +3549,423 @@ async function run() {
1834
3549
  testDmarcForensicPrototypePollutionSafe();
1835
3550
  await testIprevValidatesPtrShape();
1836
3551
  await testArcHeaderSourceOrder();
3552
+ await testSpfVerifyInputValidation();
3553
+ await testSpfAllQualifierVerdicts();
3554
+ await testSpfNeutralWhenNoMechanismMatches();
3555
+ await testSpfIp6Mechanism();
3556
+ await testSpfHeloFallbackIdentity();
3557
+ await testSpfIncludeSemantics();
3558
+ await testSpfRedirectNoRecordPermerror();
3559
+ await testSpfMacroLettersAndTransformers();
3560
+ testSpfParseRecordStructure();
3561
+ testDmarcParseDefaultsAndErrors();
3562
+ await testDmarcEvaluateFromValidation();
3563
+ await testDmarcEvaluatePolicyNoneDelivers();
3564
+ await testDmarcEvaluateNoRecordIsNone();
3565
+ await testDmarcEvaluatePermerrorOnBadRecord();
3566
+ await testDmarcEvaluateDkimResultShapes();
3567
+ await testDmarcEvaluateStrictVsRelaxedAlignment();
3568
+ await testArcVerifyInputValidation();
3569
+ await testArcVerifyMissingCvArm();
3570
+ await testArcVerifyTimeFaultArms();
3571
+ testAuthResultsEmitValidation();
3572
+ testAuthResultsEmitFormatting();
3573
+ await testSpfMacroEscapesAndLetters();
3574
+ await testSpfMacroIpv6Letters();
3575
+ await testSpfMacroBadSyntaxMore();
3576
+ await testSpfCidrMaskEdges();
3577
+ await testSpfMultipleRecordsPermerror();
3578
+ await testSpfLookupFailureTemperror();
3579
+ await testSpfLookupLimitExceeded();
3580
+ await testSpfAMxErrorBranches();
3581
+ await testSpfExistsTemperror();
3582
+ await testSpfIncludeErrorBranches();
3583
+ await testDmarcTemperror();
3584
+ await testDmarcPctSampledDispositions();
3585
+ await testDmarcAggregatePreParsedAndErrors();
3586
+ await testArcKeyErrorBranches();
3587
+ await testInboundNoIdentitySpfNone();
3588
+ await testInboundHeloOnlyAuthResults();
3589
+ await testSpfAMxOperatorEnodata();
3590
+ await testSpfADualCidrErrorArms();
3591
+ await testSpfMacroLeftTruncation();
3592
+ await testSpfExistsLookupLimit();
3593
+ testDmarcParseBadSp();
3594
+ await testDmarcEvaluatePsdPolicy();
3595
+ await testArcNoBodyAndFolded();
3596
+ await testArcEvaluateTrustedSealersNotArray();
3597
+ testAuthResultsEmitVersionControlChars();
3598
+ testDmarcAggregateSizeAndParseErrors();
3599
+ testDmarcBuildTooManyRecords();
3600
+ testDmarcForensicMoreErrorArms();
3601
+ await testSpfMxExpansionLimits();
3602
+ await testSpfExistsExpandsEmpty();
3603
+ await testSpfRedirectDepthLimit();
3604
+ await testArcKeyNonArrayTxt();
3605
+ await testSpfMalformedCidrMasksNoMatch();
3606
+ await testSpfTxtPresentButNoSpf1IsNone();
3607
+ await testSpfHeloMacroExpansion();
3608
+ await testDmarcAlignmentMissingAndGarbageAuthDomain();
3609
+ await testDmarcPctOutOfRangeClampsTo100();
3610
+ await testDmarcEvaluateNoDkimArgument();
3611
+ await testDmarcDomainExistsCallbackThrows();
3612
+ await testArcSealTimeFaults();
3613
+ await testArcKeyEnodataIsPermerror();
3614
+ await testIprevOversizePtrShapePermerror();
3615
+ await testIprevForwardCodelessErrorIsTemperror();
3616
+ testDmarcAggregateMinimalShape();
3617
+ testDmarcAggregateBuildMinimalShape();
3618
+ await testInboundVerifyDkimTuningOpts();
3619
+ // Branch-coverage round — verifier error/edge arms driven through the
3620
+ // exported b.mail.* consumer paths (offline via the dnsLookup DI seam +
3621
+ // real node:crypto ARC/DKIM fixtures).
3622
+ await testArcTimeFaultUnparseableArms();
3623
+ await testArcCvFailTerminalChain();
3624
+ await testArcKeyLookupTemperrorArm();
3625
+ await testArcEd25519RoundtripPass();
3626
+ await testArcVerifyBareLfMessageVerifies();
3627
+ await testSpfDnsFaultMessagelessErrors();
3628
+ await testSpfEmptyArgAndBadRedirectMacro();
3629
+ await testDmarcMultiRecordAndNoMatchAreNone();
3630
+ await testIprevRootDotPtrAndBareFaults();
3631
+ await testInboundBareLfMessageProducesVerdict();
3632
+ await testInboundHeadersOnlyProducesVerdict();
3633
+ await testInboundFromEscapeAndInvalidHostname();
3634
+ }
3635
+
3636
+ // A resolver error object with an empty .message — exercises the
3637
+ // `(e && e.message) || String(e)` fallback in every DNS-fault classifier,
3638
+ // proving the fault surfaces as a fail-closed verdict even when the error is
3639
+ // degenerate (never an uncaught throw).
3640
+ function _bareErr(code) { var e = new Error(); e.code = code; return e; }
3641
+
3642
+ // ---- ARC: time-fault fail-closed arms (RFC 8617 §5.2) ----
3643
+
3644
+ async function testArcTimeFaultUnparseableArms() {
3645
+ // A PRESENT-but-unparseable t=/x= on the AMS or AS must FAIL CLOSED (a time
3646
+ // fault), never silently skip the future/expiry gate. b= is a dummy: the
3647
+ // time-fault check runs BEFORE any signature verify, so a malformed
3648
+ // timestamp sinks the chain on structure alone (no key needed).
3649
+ var synthetic =
3650
+ "ARC-Seal: i=1; a=rsa-sha256; cv=none; d=relay.example; s=arc; t=abc; x=abc; b=AAAA\r\n" +
3651
+ "ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=relay.example; s=arc; t=abc; h=from; bh=AAAA; b=AAAA\r\n" +
3652
+ "ARC-Authentication-Results: i=1; relay.example; spf=pass\r\n" +
3653
+ "From: alice@example.com\r\nTo: bob@example.com\r\n\r\nbody\r\n";
3654
+ var rv = await b.mail.arc.verify(synthetic, { dnsLookup: async function () { return _enotfound(); } });
3655
+ var faultErrs = ((rv.hops[0] || {}).amsErrors || []).concat((rv.hops[0] || {}).asErrors || []).join(" ; ");
3656
+ check("arc.verify: unparseable t=/x= on AMS+AS → chain fails closed (no signature ever checked)",
3657
+ rv.chainStatus === "fail" && /unparseable/.test(faultErrs));
3658
+ }
3659
+
3660
+ // ---- ARC: cv=fail terminal chain (RFC 8617 §5.2) ----
3661
+
3662
+ async function testArcCvFailTerminalChain() {
3663
+ // A chain whose LAST hop seals cv=fail is a terminal failure
3664
+ // ("last-as-cv=fail"): every AMS/AS signature verifies and no hop rule is
3665
+ // violated, yet the sealer itself attested the chain broke — so the
3666
+ // verifier MUST report chainStatus=fail, not pass on the valid signatures.
3667
+ // Unique sealer domain — the DKIM public-key cache (lib/mail-dkim.js) is
3668
+ // process-local + keyed by <selector>._domainkey.<domain>, so a qname reused
3669
+ // across tests would return an earlier test's key.
3670
+ var arcKey = nodeCrypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
3671
+ var arcKeyPem = arcKey.privateKey.export({ format: "pem", type: "pkcs8" });
3672
+ var spkiB64 = arcKey.publicKey.export({ type: "spki", format: "der" }).toString("base64");
3673
+ var keyDns = _arcKeyDns("arc._domainkey.relay-cvfail.test", [["v=DKIM1; k=rsa; p=" + spkiB64]]);
3674
+ var rfc822 =
3675
+ "From: alice@example.com\r\nTo: bob@example.com\r\nSubject: cvfail\r\n" +
3676
+ "Date: Wed, 06 May 2026 12:00:00 +0000\r\nMessage-ID: <cvf-1@example.com>\r\n\r\nbody body\r\n";
3677
+ var hop1 = b.mail.arc.sign({ rfc822: rfc822, instance: 1, authservId: "relay-cvfail.test",
3678
+ domain: "relay-cvfail.test", selector: "arc", privateKey: arcKeyPem, algorithm: "rsa-sha256",
3679
+ cv: "none", authResults: "spf=pass smtp.mailfrom=alice@example.com",
3680
+ headersToSign: ["From", "To", "Subject", "Date", "Message-ID"] });
3681
+ var hop2 = b.mail.arc.sign({ rfc822: hop1.rfc822, instance: 2, authservId: "relay-cvfail.test",
3682
+ domain: "relay-cvfail.test", selector: "arc", privateKey: arcKeyPem, algorithm: "rsa-sha256",
3683
+ cv: "fail", authResults: "spf=pass smtp.mailfrom=alice@example.com",
3684
+ headersToSign: ["From", "To", "Subject", "Date", "Message-ID"] });
3685
+ var rv = await b.mail.arc.verify(hop2.rfc822, { dnsLookup: keyDns });
3686
+ var allSigsPass = rv.hops.length === 2 &&
3687
+ rv.hops.every(function (h) { return h.amsResult === "pass" && h.asResult === "pass"; });
3688
+ check("arc.verify: cv=fail terminal seal → chainStatus fail even though every signature verifies",
3689
+ rv.chainStatus === "fail" && rv.reason === "last-as-cv=fail" &&
3690
+ rv.cv === "fail" && allSigsPass);
3691
+ }
3692
+
3693
+ // ---- ARC: transient key-lookup fault → temperror (RFC 8617 §5.1.2) ----
3694
+
3695
+ async function testArcKeyLookupTemperrorArm() {
3696
+ // A TRANSIENT key-record DNS fault (SERVFAIL/timeout, not the definitive
3697
+ // ENOTFOUND/ENODATA) is a temperror, not a permerror: permanently refusing
3698
+ // a sealed chain on a resolver blip would drop legitimate mail. Both AMS
3699
+ // (via the DKIM key lookup) and AS surface temperror.
3700
+ // Unique d= so the DKIM key cache can't shadow the transient dnsLookup.
3701
+ var fullHop =
3702
+ "ARC-Seal: i=1; a=rsa-sha256; cv=none; d=temperror-rt.test; s=arc; b=AAAA\r\n" +
3703
+ "ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=temperror-rt.test; s=arc; bh=AAAA; h=from; b=AAAA\r\n" +
3704
+ "ARC-Authentication-Results: i=1; temperror-rt.test; spf=pass\r\n" +
3705
+ "From: alice@example.com\r\nTo: bob@example.com\r\n\r\nbody\r\n";
3706
+ var rv = await b.mail.arc.verify(fullHop, { dnsLookup: _transientDns() });
3707
+ check("arc.verify: transient key-lookup fault → temperror (not permerror)",
3708
+ rv.chainStatus === "fail" &&
3709
+ (rv.hops[0] || {}).asResult === "temperror" &&
3710
+ (rv.hops[0] || {}).amsResult === "temperror");
3711
+
3712
+ // A degenerate resolver error with no .message still classifies as a
3713
+ // temperror verdict (String(e) fallback), never an uncaught throw.
3714
+ var bareDns = async function () { throw _bareErr("ESERVFAIL"); };
3715
+ var rv2 = await b.mail.arc.verify(fullHop, { dnsLookup: bareDns });
3716
+ check("arc.verify: message-less transient key error → temperror verdict",
3717
+ (rv2.hops[0] || {}).asResult === "temperror");
3718
+ }
3719
+
3720
+ // ---- ARC: Ed25519 roundtrip verify (null-digest AS path) ----
3721
+
3722
+ async function testArcEd25519RoundtripPass() {
3723
+ // Ed25519 seal: the AS verifies through the node null-digest Ed25519 path
3724
+ // (a=ed25519-sha256 maps to a null hash algorithm). Prior ARC roundtrip
3725
+ // coverage was RSA-only, so the Ed25519 AS verify arm never ran.
3726
+ var edKey = nodeCrypto.generateKeyPairSync("ed25519");
3727
+ var edKeyPem = edKey.privateKey.export({ format: "pem", type: "pkcs8" });
3728
+ var edSpki = edKey.publicKey.export({ type: "spki", format: "der" }).toString("base64");
3729
+ var edDns = _arcKeyDns("arc._domainkey.relay-ed-rt.test", [["v=DKIM1; k=ed25519; p=" + edSpki]]);
3730
+ var rfc822 =
3731
+ "From: alice@example.com\r\nTo: bob@example.com\r\nSubject: ed\r\n" +
3732
+ "Date: Wed, 06 May 2026 12:00:00 +0000\r\nMessage-ID: <ed-1@example.com>\r\n\r\nbody body\r\n";
3733
+ var hop = b.mail.arc.sign({ rfc822: rfc822, instance: 1, authservId: "relay-ed-rt.test",
3734
+ domain: "relay-ed-rt.test", selector: "arc", privateKey: edKeyPem, algorithm: "ed25519-sha256",
3735
+ cv: "none", authResults: "spf=pass smtp.mailfrom=alice@example.com",
3736
+ headersToSign: ["From", "To", "Subject", "Date", "Message-ID"] });
3737
+ var rv = await b.mail.arc.verify(hop.rfc822, { dnsLookup: edDns });
3738
+ check("arc.verify: Ed25519 single-hop chain → pass (AS verifies via null-digest path)",
3739
+ rv.chainStatus === "pass" && rv.hops[0].asResult === "pass" && rv.hops[0].amsResult === "pass");
3740
+ }
3741
+
3742
+ // ---- ARC: bare-LF message reaches a chain verdict (not a thrown DkimError) ----
3743
+
3744
+ async function testArcVerifyBareLfMessageVerifies() {
3745
+ // arc.verify's own header scan accepts bare-LF, but the AMS step reuses the
3746
+ // DKIM verifier (CRLF-only split) — a bare-LF ARC message threw an uncaught
3747
+ // DkimError out of arc.verify instead of producing a chain result. A chain
3748
+ // signed over CRLF and transported as bare-LF must still verify (the LF→CRLF
3749
+ // normalization restores the sealer's canonical bytes).
3750
+ var arcKey = nodeCrypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
3751
+ var arcKeyPem = arcKey.privateKey.export({ format: "pem", type: "pkcs8" });
3752
+ var spkiB64 = arcKey.publicKey.export({ type: "spki", format: "der" }).toString("base64");
3753
+ var keyDns = _arcKeyDns("arc._domainkey.relay-arclf.test", [["v=DKIM1; k=rsa; p=" + spkiB64]]);
3754
+ var rfc822 =
3755
+ "From: alice@example.com\r\nTo: bob@example.com\r\nSubject: arclf\r\n" +
3756
+ "Date: Wed, 06 May 2026 12:00:00 +0000\r\nMessage-ID: <arclf-1@example.com>\r\n\r\nbody body\r\n";
3757
+ var hop = b.mail.arc.sign({ rfc822: rfc822, instance: 1, authservId: "relay-arclf.test",
3758
+ domain: "relay-arclf.test", selector: "arc", privateKey: arcKeyPem, algorithm: "rsa-sha256",
3759
+ cv: "none", authResults: "spf=pass smtp.mailfrom=alice@example.com",
3760
+ headersToSign: ["From", "To", "Subject", "Date", "Message-ID"] });
3761
+ var lf = hop.rfc822.replace(/\r\n/g, "\n");
3762
+ var rv = await b.mail.arc.verify(lf, { dnsLookup: keyDns });
3763
+ check("arc.verify: CRLF-signed chain transported as bare-LF → pass (verdict, not a thrown DkimError)",
3764
+ rv.chainStatus === "pass" && rv.hops[0].amsResult === "pass" && rv.hops[0].asResult === "pass");
3765
+ }
3766
+
3767
+ // ---- SPF: message-less DNS faults still fail closed to temperror ----
3768
+
3769
+ async function testSpfDnsFaultMessagelessErrors() {
3770
+ // RFC 7208 §4.6.4 / §5 — every SPF a/mx/exists/include DNS fault that is
3771
+ // not a definitive negative answer is a temperror (fail-closed). A
3772
+ // degenerate resolver that throws an error with no .message must STILL
3773
+ // yield a temperror VERDICT via the String(e) fallback — never an uncaught
3774
+ // throw a pipeline could mis-handle as accept.
3775
+ var aDns = async function (host, type) {
3776
+ if (type === "TXT" && host === "s.example") return [["v=spf1 a -all"]];
3777
+ if (type === "A") throw _bareErr("ESERVFAIL");
3778
+ return _enotfound();
3779
+ };
3780
+ check("spf a: message-less A fault → temperror verdict",
3781
+ (await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: aDns })).result === "temperror");
3782
+
3783
+ var mxDns = async function (host, type) {
3784
+ if (type === "TXT" && host === "s.example") return [["v=spf1 mx -all"]];
3785
+ if (type === "MX") throw _bareErr("ESERVFAIL");
3786
+ return _enotfound();
3787
+ };
3788
+ check("spf mx: message-less MX fault → temperror verdict",
3789
+ (await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: mxDns })).result === "temperror");
3790
+
3791
+ var mxHostDns = async function (host, type) {
3792
+ if (type === "TXT" && host === "s.example") return [["v=spf1 mx -all"]];
3793
+ if (type === "MX") return [{ exchange: "mx1.s.example", preference: 10 }];
3794
+ if (type === "A") throw _bareErr("ESERVFAIL");
3795
+ return _enotfound();
3796
+ };
3797
+ check("spf mx-host: message-less forward fault → temperror verdict",
3798
+ (await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: mxHostDns })).result === "temperror");
3799
+
3800
+ var existsDns = async function (host, type) {
3801
+ if (type === "TXT" && host === "s.example") return [["v=spf1 exists:p.%{d} -all"]];
3802
+ if (type === "A") throw _bareErr("ESERVFAIL");
3803
+ return _enotfound();
3804
+ };
3805
+ check("spf exists: message-less A fault → temperror verdict",
3806
+ (await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: existsDns })).result === "temperror");
3807
+
3808
+ var incDns = async function (host, type) {
3809
+ if (type === "TXT" && host === "s.example") return [["v=spf1 include:inner.example -all"]];
3810
+ if (type === "TXT" && host === "inner.example") throw _bareErr("ESERVFAIL");
3811
+ return _enotfound();
3812
+ };
3813
+ check("spf include: message-less inner TXT fault → temperror verdict",
3814
+ (await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: incDns })).result === "temperror");
3815
+ }
3816
+
3817
+ // ---- SPF: empty-arg mechanisms + bad redirect macro ----
3818
+
3819
+ async function testSpfEmptyArgAndBadRedirectMacro() {
3820
+ // RFC 7208 §5.2/§5.7 — an `include:` / `exists:` with an empty argument
3821
+ // carries no target; the mechanism is skipped rather than matching, so
3822
+ // evaluation falls through to the trailing `-all` → fail (fail-closed).
3823
+ var emptyArg = _txtOnly({ "s.example": [["v=spf1 include: exists: -all"]] });
3824
+ var rEmpty = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: emptyArg });
3825
+ check("spf: empty-arg include:/exists: are skipped → falls through to -all fail",
3826
+ rEmpty.result === "fail");
3827
+
3828
+ // RFC 7208 §6.1 / §7 — a redirect= target is a macro-string; an invalid
3829
+ // macro-letter in it is a permanent syntax error → permerror.
3830
+ var badRedir = _txtOnly({ "s.example": [["v=spf1 redirect=%{z}"]] });
3831
+ var rRedir = await b.mail.spf.verify({ ip: "192.0.2.5", mailFrom: "a@s.example", dnsLookup: badRedir });
3832
+ check("spf: redirect= with an invalid macro-letter → permerror",
3833
+ rRedir.result === "permerror" && /not a valid macro-letter/.test(rRedir.explanation || ""));
3834
+ }
3835
+
3836
+ // ---- DMARC: ambiguous / absent record → none (RFC 7489 §6.6.3) ----
3837
+
3838
+ async function testDmarcMultiRecordAndNoMatchAreNone() {
3839
+ // When a domain publishes MORE THAN ONE v=DMARC1 record the receiver MUST
3840
+ // treat it as having no DMARC record (the policy is ambiguous).
3841
+ var multi = async function (host, type) {
3842
+ if (type === "TXT" && host === "_dmarc.example.com")
3843
+ return [["v=DMARC1; p=reject"], ["v=DMARC1; p=none"]];
3844
+ return _enotfound();
3845
+ };
3846
+ var rMulti = await b.mail.dmarc.evaluate({ from: "a@example.com",
3847
+ spf: { result: "pass", domain: "example.com" }, dkim: [], dnsLookup: multi });
3848
+ check("dmarc.evaluate: two v=DMARC1 records → none (ambiguous, RFC 7489 §6.6.3)",
3849
+ rMulti.result === "none");
3850
+
3851
+ // TXT present at _dmarc but no record begins with v=DMARC1 → no policy → none.
3852
+ var noV = async function (host, type) {
3853
+ if (type === "TXT" && host === "_dmarc.example.com")
3854
+ return [["some=unrelated txt"], ["v=spf1 nope"]];
3855
+ return _enotfound();
3856
+ };
3857
+ var rNoV = await b.mail.dmarc.evaluate({ from: "a@example.com",
3858
+ spf: { result: "pass", domain: "example.com" }, dkim: [], dnsLookup: noV });
3859
+ check("dmarc.evaluate: _dmarc TXT with no v=DMARC1 record → none",
3860
+ rNoV.result === "none");
3861
+ }
3862
+
3863
+ // ---- iprev: degenerate PTR + message-less faults (RFC 8601 §3) ----
3864
+
3865
+ async function testIprevRootDotPtrAndBareFaults() {
3866
+ // A PTR answer that is only the root label (".") collapses to an empty name
3867
+ // after the trailing-dot strip — RFC 8601 §3 has no usable PTR, so iprev
3868
+ // returns fail (no PTR record), never a crash on the empty name.
3869
+ var rootDot = async function (qname, type) {
3870
+ if (type === "PTR") return ["."];
3871
+ return _enotfound();
3872
+ };
3873
+ var rRoot = await b.mail.iprev.verify("203.0.113.5", { dnsLookup: rootDot });
3874
+ check("iprev.verify: root-only PTR answer → fail (no usable PTR name)",
3875
+ rRoot.result === "fail" && rRoot.ptr === null);
3876
+
3877
+ // Message-less transient reverse fault → temperror verdict (String(e)).
3878
+ var revBare = async function (qname, type) {
3879
+ if (type === "PTR") throw _bareErr("ESERVFAIL");
3880
+ return _enotfound();
3881
+ };
3882
+ check("iprev.verify: message-less reverse fault → temperror verdict",
3883
+ (await b.mail.iprev.verify("203.0.113.5", { dnsLookup: revBare })).result === "temperror");
3884
+
3885
+ // Code-less AND message-less forward fault → temperror verdict (String(e)
3886
+ // fallback in the forward-lookup explanation).
3887
+ var fwdBare = async function (qname, type) {
3888
+ if (type === "PTR") return ["mail.example.com"];
3889
+ if (type === "A") throw new Error("");
3890
+ return _enotfound();
3891
+ };
3892
+ check("iprev.verify: code-less/message-less forward fault → temperror verdict",
3893
+ (await b.mail.iprev.verify("203.0.113.5", { dnsLookup: fwdBare })).result === "temperror");
3894
+ }
3895
+
3896
+ // ---- inbound.verify: bare-LF + headers-only messages reach a verdict ----
3897
+
3898
+ async function testInboundBareLfMessageProducesVerdict() {
3899
+ // inbound.verify documents that bare-LF input is accepted defensively
3900
+ // (operator tooling that lost CRs). The DKIM verifier's header/body split
3901
+ // REQUIRES CRLF CRLF and threw an uncaught DkimError on bare-LF, crashing
3902
+ // the pipeline instead of producing a verdict. A bare-LF message must reach
3903
+ // a verdict.
3904
+ var lfPlain = "From: alice@example.com\nTo: bob@x\nSubject: hi\n\nbody\n";
3905
+ var vPlain = await b.mail.inbound.verify({ ip: "203.0.113.5", message: lfPlain,
3906
+ dnsLookup: async function () { return _enotfound(); } });
3907
+ check("inbound.verify: bare-LF message → verdict, not a thrown DkimError",
3908
+ vPlain.from.domain === "example.com" && Array.isArray(vPlain.dkim) &&
3909
+ vPlain.dmarc.result === "none");
3910
+
3911
+ // Stronger: a message SIGNED over CRLF but transported as bare-LF must
3912
+ // still DKIM-verify (the LF→CRLF normalization restores the signed form),
3913
+ // not merely avoid the throw.
3914
+ var pair = nodeCrypto.generateKeyPairSync("rsa", {
3915
+ modulusLength: 2048,
3916
+ publicKeyEncoding: { type: "spki", format: "pem" },
3917
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
3918
+ });
3919
+ // Unique selector — the DKIM key cache is keyed by <selector>._domainkey.<d>.
3920
+ var signer = b.mail.dkim.create({ domain: "example.com", selector: "lfrt", privateKey: pair.privateKey });
3921
+ var signed = signer.sign(
3922
+ "From: alice@example.com\r\nTo: bob@example.com\r\nSubject: hi\r\n" +
3923
+ "Date: Mon, 5 May 2026 10:00:00 +0000\r\nMessage-ID: <lf@example.com>\r\n\r\nHello world.\r\n");
3924
+ var spkiB64 = nodeCrypto.createPublicKey(pair.publicKey)
3925
+ .export({ type: "spki", format: "der" }).toString("base64");
3926
+ var keyDns = async function (qname) {
3927
+ if (qname === "lfrt._domainkey.example.com") return [["v=DKIM1; k=rsa; p=" + spkiB64]];
3928
+ if (qname === "_dmarc.example.com") return [["v=DMARC1; p=reject; adkim=s; aspf=s"]];
3929
+ return _enotfound();
3930
+ };
3931
+ var lfSigned = signed.replace(/\r\n/g, "\n");
3932
+ var vSigned = await b.mail.inbound.verify({ ip: "203.0.113.5", mailFrom: "alice@example.com",
3933
+ message: lfSigned, authservId: "mx.receiver.example", dnsLookup: keyDns });
3934
+ check("inbound.verify: CRLF-signed message transported as bare-LF still DKIM-passes",
3935
+ vSigned.dkim.length === 1 && vSigned.dkim[0].result === "pass" &&
3936
+ vSigned.dmarc.result === "pass");
3937
+ }
3938
+
3939
+ async function testInboundHeadersOnlyProducesVerdict() {
3940
+ // A headers-only message with no blank-line separator — _splitHeaderBlock
3941
+ // treats it as headers + empty body; the pipeline must produce a verdict
3942
+ // (DKIM none) rather than throwing on the missing CRLF CRLF separator.
3943
+ var headersOnly = "From: alice@example.com\r\nTo: bob@x\r\nSubject: hi";
3944
+ var v = await b.mail.inbound.verify({ ip: "203.0.113.5", message: headersOnly,
3945
+ dnsLookup: async function () { return _enotfound(); } });
3946
+ check("inbound.verify: headers-only message (no separator) → verdict (dkim none)",
3947
+ v.from.domain === "example.com" &&
3948
+ v.dkim.length >= 1 && v.dkim[0].result === "none");
3949
+ }
3950
+
3951
+ async function testInboundFromEscapeAndInvalidHostname() {
3952
+ var absent = async function () { return _enotfound(); };
3953
+
3954
+ // Quoted-pair (\") inside a display-name is one author, not a break — the
3955
+ // escape walk must consume the escaped quote and keep the angle-addr whole.
3956
+ var escMsg = 'From: "a\\"b" <u@good.example.com>\r\nTo: bob@x\r\nSubject: hi\r\n\r\nbody\r\n';
3957
+ var vEsc = await b.mail.inbound.verify({ ip: "203.0.113.5", message: escMsg, dnsLookup: absent });
3958
+ check("inbound.verify: From with a quoted-pair display-name → single author extracted",
3959
+ vEsc.from.count === 1 && vEsc.from.address === "u@good.example.com" &&
3960
+ vEsc.from.domain === "good.example.com");
3961
+
3962
+ // A single-label From domain (no dot) is not a valid DNS hostname for a
3963
+ // DMARC lookup → domain treated as unparsable → fail-closed permerror.
3964
+ var singleLabel = "From: alice@localhost\r\nTo: bob@x\r\nSubject: hi\r\n\r\nbody\r\n";
3965
+ var vSingle = await b.mail.inbound.verify({ ip: "203.0.113.5", message: singleLabel, dnsLookup: absent });
3966
+ check("inbound.verify: single-label From domain → unparsable → dmarc permerror (reject)",
3967
+ vSingle.from.domain === null && vSingle.dmarc.result === "permerror" &&
3968
+ vSingle.dmarc.recommendedAction === "reject");
1837
3969
  }
1838
3970
 
1839
3971
  module.exports = { run: run };