import { parseTtl, getHasValidTtl } from '../ttl/parse_ttl'; import { ParserInvalidArgumentsError } from '../errors/parsing_error_invalid_arguments'; import { getRecordNameUsingOrigin, zipArrayToObj } from './utils_parser'; import { ZoneRecordTypings as RecordTypings, ParsedTypings, } from '../shared/types_domain_specific'; import { SUPPORTED_RECORD_TYPES, ErrorKeyKind, RecordMinLengthKind, RecordMaxLengthKind, DirectiveKind, RecordType, } from '../shared/constants_domain_specific'; import { ParserTypings, InputTypings, ParsingRecordDataType, } from './types_parser'; /** nameAndTtl, values, min, max */ type Lengths = [number, number, number, number]; /** we indexed how long each value must be, so subtract $NAME_TTL + $IN_TYPE + $VALUES */ const getLengths = ( recordType: RecordType, values: unknown[], lineParts: string[] ): Lengths => { const valuesLength = values.length; const minLength = RecordMinLengthKind[recordType]; const maxLength = RecordMaxLengthKind[recordType]; const nameAndTtlLength = lineParts.length - valuesLength - 2; return [nameAndTtlLength, valuesLength, minLength, maxLength]; }; /** * @idea we can include this in validation so it can be extended * @note this uses Lengths type (everything after nameAndTtl) * @throws {ParserInvalidArgumentsError} with invalid input lengths */ export const assertRecordLengths = ( recordType: RecordType, valuesLength: number, minLength: number, maxLength: number ) => { const isExact = +minLength === +maxLength; const isExactAndNotEq = isExact && minLength !== valuesLength; if (isExactAndNotEq || minLength > valuesLength) { throw new ParserInvalidArgumentsError(recordType, valuesLength, minLength); } }; /** * $NAME_TTL = [name] [ttl] * $IN_TYPE = `IN $TYPE` * * [name] [ttl] - IN $TYPE - value < 4 * [ttl] - IN $TYPE - value < 3 * - IN $TYPE - value < 2 * * [ttl] [name] - IN TYPE - value < this is not supported by prod * * these 2 are where it gets tricky - do we prefer name, or ttl? * [name] - IN TYPE - value * [ttl] - IN TYPE - value * * @perf bench & experiment as a class * @note has sideEffects - may swap ttl order in array */ const normalizeRecord = ( lineContent: string, lineParts: string[], recordType: InputTypings.SupportedRecordType, zoneObj: ParsedTypings.Obj, previousName: string ): ParsingRecordDataType => { /** add `IN` if it does not exist */ const typeIndex = lineParts.lastIndexOf(recordType); let newName: string = ''; if (typeIndex === 0 || lineParts[typeIndex - 1] !== 'IN') { lineParts.splice(typeIndex, 0, 'IN'); } /** remove duplicate `IN` */ const firstInIndex = lineParts.indexOf('IN'); const lastInIndex = lineParts.lastIndexOf('IN'); if (firstInIndex !== lastInIndex) { lineParts.splice(firstInIndex, 1); } const values = lineParts.slice(lineParts.lastIndexOf(recordType) + 1); const [nameAndTtlLength, ...rest] = getLengths(recordType, values, lineParts); assertRecordLengths(recordType, ...rest); /** * @todo @perf move to separate fn * @spec RFC-1035: contents are oneOf: * @example * `[] [] ` // assume this one * `[] [] ` // but also support this one */ const getHas = () => { switch (nameAndTtlLength) { // we have name || ttl case 1: { // does not start with a space (if so, then it's a child of the parent [as long as parent is same type]) const hasName = /^\s+/.test(lineContent) === false; return { hasTtl: !hasName, hasName, }; } // ([name, ttl] | [ttl, name]) => [name, ttl] case 2: { const [first, second] = lineParts; const didFirstHaveTtl = getHasValidTtl(first); const didSecondHaveTtl = getHasValidTtl(second); const hasTtl = didFirstHaveTtl || didSecondHaveTtl; // swap order if (didFirstHaveTtl && !didSecondHaveTtl) { lineParts[0] = second; lineParts[1] = first; } return { hasTtl, hasName: lineParts[0] !== '', }; } // no name or ttl, we can use the top level case 0: { return { hasTtl: false, hasName: false, }; } // too many args for name + ttl default: { throw new Error(recordType); } } }; const { hasName, hasTtl } = getHas(); const normalized = { values, recordType, tokens: lineParts, }; // unshift name if (!hasName) { const recordsSoFar = zoneObj[recordType]; /** @idea simplify by removing the else if here since we have previousName now */ if (previousName) { normalized.tokens.unshift(previousName); } else if (Array.isArray(recordsSoFar) && recordsSoFar.length > 0) { normalized.tokens.unshift( recordsSoFar[recordsSoFar.length - 1].name || '@' ); } else { normalized.tokens.unshift('@'); } } else { newName = normalized.tokens[0]; } // unshift ttl if (hasTtl) { normalized.tokens[1] = `${parseTtl(normalized.tokens[1])[0]}`; } else { // move name to 0, then put ttl at 1 normalized.tokens.unshift(normalized.tokens[0]); // we want the tokens to stay as strings, so we cast the numerical $ttl to a string // if there is not a ttl, we have added an `undefined` to this array normalized.tokens[1] = zoneObj.$TTL === undefined ? (undefined as any) : `${zoneObj.$TTL}`; } const [nameValue, ttlValue] = normalized.tokens; const finalName = getRecordNameUsingOrigin(nameValue, zoneObj); return { ...normalized, name: finalName, ttl: typeof ttlValue === 'string' ? +ttlValue : undefined, previousName: newName || previousName, }; }; const fromAtSymbolValuesToOrigin = < Record extends ParsedTypings.ByType[keyof ParsedTypings.ByType] >( { values }: Record, { zoneObj }: ParserTypings.Parser ) => { return values.map(x => { // replace @ with origin if (x === '@' && zoneObj.$ORIGIN) return zoneObj.$ORIGIN; // escaped values else if (x === '\\@') return '@'; // nothing changed return x; }) as Record['values']; }; export const parsers = { NS: zipArrayToObj('host', (record, parser) => ({ ...record, values: fromAtSymbolValuesToOrigin(record, parser), })), A: zipArrayToObj('ip', (record, parser) => ({ ...record, values: fromAtSymbolValuesToOrigin(record, parser), })), AAAA: zipArrayToObj('ip', (record, parser) => ({ ...record, values: fromAtSymbolValuesToOrigin(record, parser), })), CNAME: zipArrayToObj('alias', (record, parser) => ({ ...record, values: fromAtSymbolValuesToOrigin(record, parser), })), MX: zipArrayToObj('preference host', (record, parser) => ({ ...record, values: fromAtSymbolValuesToOrigin(record, parser), })), TXT: zipArrayToObj('txt'), PTR: zipArrayToObj('host', (record, parser) => ({ ...record, values: fromAtSymbolValuesToOrigin(record, parser), })), SRV: zipArrayToObj('target port weight priority'), NAPTR: zipArrayToObj( 'order preference flags services regexp replacement' ), SPF: zipArrayToObj('data', x => ({ ...x, values: [x.values.join(' ').trim()], })), CAA: zipArrayToObj('flags tag data', x => ({ ...x, data: x.data.replace(/^"(.+?)"$/, '$1'), })), SOA: zipArrayToObj( 'minimum expire retry refresh serial rname mname' ) as ( x: Omit ) => RecordTypings.SOA, } as const; const originVisitor: ParserTypings.Visitor = { isSatisfied: ({ lineParts }) => lineParts.indexOf('$ORIGIN') === 0, visit(parser, { lineParts, addMeta }) { parser.zoneObj.$ORIGIN = lineParts[1]; return addMeta({ instructionType: DirectiveKind.Origin, value: lineParts[1], }); }, }; const ttlVisitor: ParserTypings.Visitor = { isSatisfied: ({ lineParts }) => lineParts.indexOf('$TTL') === 0, visit({ zoneObj }, { addMeta, lineParts }) { const [ttl, error] = parseTtl(lineParts[1]); if (!error) { zoneObj.$TTL = ttl; addMeta({ instructionType: DirectiveKind.TimeToLive, value: ttl, }); } else { addMeta({ instructionType: DirectiveKind.TimeToLive, value: ttl, errorType: error, error, }); } }, }; const soaVisitor: ParserTypings.Visitor = { isSatisfied: ({ lineParts }) => lineParts.includes('SOA'), visit({ zoneObj }, { addMeta, lineParts }) { zoneObj.SOA = parsers.SOA({ tokens: lineParts, values: lineParts.slice(lineParts.indexOf('SOA') + 1), }); addMeta({ instructionType: 'SOA', value: zoneObj.SOA }); }, }; /** * intersection. * checks whether the array of strings include any of the supported record types */ const getSupportedType = (lineParts: string[]) => SUPPORTED_RECORD_TYPES.find(supportedType => lineParts.includes(supportedType) ); const simpleRecordsVisitor: ParserTypings.Visitor = { isSatisfied: ({ lineParts }) => !!getSupportedType(lineParts), visit(parser, { addMeta, lineParts, lineContent }) { const { zoneObj, stack } = parser; const type = getSupportedType(lineParts) as RecordType; // make sure the list exists if (!Array.isArray(zoneObj[type])) { zoneObj[type] = [] as any[]; } // reference the array and because we defaulted it above, assert the type const listForType = zoneObj[type] as RecordTypings.AnyIndexSignature[]; try { const previousName = stack[stack.length - 1]?.name ?? ''; const normalized = normalizeRecord( lineContent, lineParts, type, zoneObj, previousName ); stack.push(normalized); const parsed = parsers[type](normalized, parser); listForType.push(parsed); addMeta({ instructionType: type, value: parsed }); } catch (error) { addMeta({ instructionType: type, value: lineParts.join(' '), error, errorType: error instanceof ParserInvalidArgumentsError ? ErrorKeyKind.WrongLength : ErrorKeyKind.WrongNameArgsLength, }); } }, }; const fallbackVisitor: ParserTypings.Visitor = { isSatisfied: () => true, visit(parser, { addMeta, lineParts }) { addMeta({ instructionType: ErrorKeyKind.Unknown, errorType: ErrorKeyKind.BadDirective, value: lineParts.join(' '), }); }, }; export const visitors = [ originVisitor, ttlVisitor, soaVisitor, simpleRecordsVisitor, fallbackVisitor, ];