export type TTxtContentDecodeResult = | { ok: true; value: string; wasPresentation: boolean } | { ok: false; error: string }; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder('utf-8', { fatal: true }); function appendCharacterBytes(target: number[], character: string): void { target.push(...textEncoder.encode(character)); } /** * Decode the representation returned by DNS provider APIs for TXT records. * * Cloudflare uses RFC 1035 presentation syntax (one or more adjacent quoted * character strings). Some older API responses and dcrouter mirrors contain * the already-decoded payload, so an unquoted input is accepted as canonical. * A value that starts as presentation syntax must parse completely; malformed * quoting or escaping is never repaired by stripping quote characters. */ export function decodeTxtRecordContent(input: string): TTxtContentDecodeResult { const source = input.trim(); if (!source.startsWith('"')) { return { ok: true, value: source, wasPresentation: false }; } const bytes: number[] = []; let index = 0; let chunkCount = 0; while (index < source.length) { while (/\s/.test(source[index] || '')) index++; if (index >= source.length) break; if (source[index] !== '"') { return { ok: false, error: `Unexpected TXT presentation content at offset ${index}` }; } chunkCount++; index++; let closed = false; while (index < source.length) { const character = source[index]; if (character === '"') { index++; closed = true; break; } if (character === '\\') { index++; if (index >= source.length) { return { ok: false, error: 'TXT presentation ends with an incomplete escape' }; } if (/\d/.test(source[index])) { const decimalEscape = source.slice(index, index + 3); if (!/^\d{3}$/.test(decimalEscape)) { return { ok: false, error: `Invalid TXT decimal escape at offset ${index - 1}` }; } const byte = Number.parseInt(decimalEscape, 10); if (byte > 255) { return { ok: false, error: `TXT decimal escape exceeds 255 at offset ${index - 1}` }; } bytes.push(byte); index += 3; continue; } const escapedCodePoint = source.codePointAt(index); if (escapedCodePoint === undefined) { return { ok: false, error: `Invalid TXT escape at offset ${index - 1}` }; } const escapedCharacter = String.fromCodePoint(escapedCodePoint); appendCharacterBytes(bytes, escapedCharacter); index += escapedCharacter.length; continue; } const codePoint = source.codePointAt(index); if (codePoint === undefined) { return { ok: false, error: `Invalid TXT character at offset ${index}` }; } const nextCharacter = String.fromCodePoint(codePoint); appendCharacterBytes(bytes, nextCharacter); index += nextCharacter.length; } if (!closed) { return { ok: false, error: 'TXT presentation contains an unterminated character string' }; } while (/\s/.test(source[index] || '')) index++; if (index < source.length && source[index] !== '"') { return { ok: false, error: `Unexpected TXT presentation content at offset ${index}` }; } } if (chunkCount === 0) { return { ok: false, error: 'TXT presentation contains no character strings' }; } try { return { ok: true, value: textDecoder.decode(Uint8Array.from(bytes)), wasPresentation: true }; } catch { return { ok: false, error: 'TXT presentation is not valid UTF-8' }; } } function escapeTxtChunk(chunk: string): string { let result = ''; for (const character of chunk) { const codePoint = character.codePointAt(0)!; if (character === '"' || character === '\\') { result += `\\${character}`; } else if (codePoint < 32 || codePoint === 127) { for (const byte of textEncoder.encode(character)) { result += `\\${byte.toString(10).padStart(3, '0')}`; } } else { result += character; } } return result; } /** Encode a canonical TXT payload as Cloudflare/RFC 1035 presentation text. */ export function encodeCloudflareTxtContent(value: string): string { const chunks: string[] = []; let current = ''; let currentBytes = 0; for (const character of value) { const characterBytes = textEncoder.encode(character).length; if (currentBytes > 0 && currentBytes + characterBytes > 255) { chunks.push(current); current = ''; currentBytes = 0; } current += character; currentBytes += characterBytes; } chunks.push(current); return chunks.map((chunk) => `"${escapeTxtChunk(chunk)}"`).join(' '); }