import { Mechanism } from "../spf/Mechanism.js"; import { ValidationError } from "../ValidationError.js"; import { Modifier } from "../spf/Modifier.js"; import { ValueRequirement } from "../spf/ValueRequirement.js"; export class SpfRecord { static fields = [ new Modifier("v") .required() .validate((key, val) => { if (val !== "spf1") throw new ValidationError(`Version must be "spf1"`); return true; }) .pos(0), new Mechanism("include") .validate(this.validateDomain) .multiple() .pos(1), new Mechanism("a") .value(ValueRequirement.OPTIONAL) .validate(this.validateDomain) .multiple() .pos(1), new Mechanism("mx") .value(ValueRequirement.OPTIONAL) .validate(this.validateDomain) .multiple() .pos(2), new Mechanism("ptr") .value(ValueRequirement.OPTIONAL) .validate(() => { throw new ValidationError(`"ptr" mechanism should not be used`) }) .multiple() .pos(2), new Mechanism("ipv4") .validate(this.validateIPv4) .multiple() .pos(2), new Mechanism("ipv6") .validate(this.validateIPv6) .multiple() .pos(2), new Mechanism("exists") .validate(this.validateDomain) .multiple() .pos(2), new Mechanism("all") .value(ValueRequirement.PROHIBITED) .pos(3), new Modifier("redirect") .validate(this.validateDomain) .pos(4), new Modifier("exp") .validate(this.validateDomain) .pos(4), ]; constructor(text) { this.text = text; } tokenize() { return this.text.trim().split(/\s+/); } getKeyValues() { const result = []; for (const token of this.tokenize()) { const name = token.match(/^[-+?~]?(\w*)/)[1]; const term = this.constructor.fields.find(f => f.key === name); if (!term) { throw new ValidationError(`Unknown term: ${name}`); } const [directive, value] = token.split(term.separator); const [, qualifier, key] = directive.match(/^([-+?~]?)(\w*)/); result.push({ qualifier, key, value }); } return result; } validate() { const values = this.getKeyValues(); for (const term of this.constructor.fields) { if (term.isRequired && !values.some(v => v.key === term.key)) { throw new ValidationError(`Term "${term.key}" is required`); } } let lastPos = 0; for (let i = 0; i < values.length; i++) { const input = values[i]; const term = this.constructor.fields.find(d => d.key === input.key); if (!term) { throw new ValidationError(`Unknown term: ${input.key}`); } if (term.position < lastPos) { const lastDirective = this.constructor.fields.find(d => d.key === values[i-1].key); throw new ValidationError(`Term "${lastDirective.key}" must come after "${term.key}"`); } if (term instanceof Modifier && input.qualifier) { throw new ValidationError(`Modifier "${term.key}" must not have a qualifier`) } if (!input.value && term.valueRequirement === ValueRequirement.REQUIRED) { throw new ValidationError(`Term "${term.key}" is missing a value`); } if (input.value && term.valueRequirement === ValueRequirement.PROHIBITED) { throw new ValidationError(`Term "${term.key}" must not have a value`); } if (input.value) term.validationFunction(term.key, input.value); lastPos = term.position; } } static validateDomain(key, value) { // https://www.rfc-editor.org/rfc/rfc7208#section-7.1 const valid = value .split(".") .every(segment => segment.match(/^([^%]|%_|%%|%-|%\{[slodiphcrtv]\d*r?[-.+,\/_=]*})+$/)); if (!valid) throw new ValidationError(`Value for "${key}" is not a valid domain name`); return true; } static validateIPv4(key, value) { const segments = value.split("."); const valid = segments.every(segment => { const number = parseInt(segment); return !isNaN(number) && number >= 0 && number <= 255; }); if (segments.length !== 4 || !valid) { throw new ValidationError(`Value for ${key} is not a valid IPv4 address`); } return true; } static validateIPv6(key, value) { return true; // TODO validate properly } }