import { Buffer } from 'node:buffer'; import { fail } from './types'; export interface MailPatch { patch: string; message: string; author?: { name: string; email: string; date: string }; changeId?: string; } function decodeHeader(value: string) { let output = '', previousEncoded = false; for (const token of value.split(' ').filter(Boolean)) { const match = /^=\?utf-8\?([QqBb])\?(.+)\?=$/i.exec(token); let text = token, encoded = false; if (match) { try { let data: Buffer; if (match[1].toLowerCase() === 'b') { if ( !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( match[2], ) ) throw Error(); data = Buffer.from(match[2], 'base64'); } else { const bytes: number[] = []; for (let i = 0; i < match[2].length; i++) { const char = match[2][i]; if (char === '_') bytes.push(32); else if (char === '=') { const hex = match[2].slice(i + 1, i + 3); if (!/^[a-fA-F0-9]{2}$/.test(hex)) throw Error(); bytes.push(parseInt(hex, 16)); i += 2; } else bytes.push(char.charCodeAt(0)); } data = Buffer.from(bytes); } text = data.toString(); encoded = true; } catch {} } if (output && !(previousEncoded && encoded)) output += ' '; output += text; previousEncoded = encoded; } return output; } export function isMailPatch(patch: string) { const lines = patch.split('\n'); return ( /^From .* Mon Sep 17 00:00:00 2001[ \t\r]*$/.test(lines[0]) || lines .slice(0, 10) .filter((line) => /^(From: |Date: |Subject: |commit )/.test(line.trim())) .length >= 2 ); } export function parseMailPatches(patch: string): MailPatch[] { const dividers = [ ...patch.matchAll(/^From .* Mon Sep 17 00:00:00 2001[ \t\r]*$/gm), ], chunks = dividers.length ? dividers.map((divider, index) => patch.slice( divider.index, dividers[index + 1]?.index ?? patch.length, ), ) : [patch]; if (chunks.length > 100) fail(413, 'OperationTooLarge', 'patch series exceeds 100 commits'); return chunks.map((chunk) => { const lines = chunk.split('\n'); if (/^From .* Mon Sep 17 00:00:00 2001[ \t\r]*$/.test(lines[0])) lines.shift(); const end = lines.findIndex((line) => !line.trim()); if (end < 0) fail(400, 'InvalidPatch', 'mail patch has no header separator'); const headers: { name: string; value: string }[] = []; for (const line of lines.slice(0, end)) { if (/^[ \t]/.test(line)) { const last = headers.at(-1); if (last) last.value += ' ' + line.trim(); } else { const colon = line.indexOf(':'); if (colon > 0) headers.push({ name: line.slice(0, colon).trim().toLowerCase(), value: line.slice(colon + 1).trim(), }); } } const header = (name: string) => headers.find((h) => h.name === name)?.value, from = header('from'); if (!from) fail(400, 'InvalidPatch', 'mail patch has no From header'); const decoded = decodeHeader(from), angle = decoded.lastIndexOf('<'); const name = (angle < 0 ? decoded : decoded.slice(0, angle)) .trim() .replace(/^"|"$/g, '') .trim(), email = ( angle < 0 ? decoded : decoded.slice(angle + 1).split('>')[0] ).trim(); if (!name || !email || /[<>\r\n\x00]/.test(name + email)) fail(400, 'InvalidPatch', 'invalid mail author'); let subject = decodeHeader(header('subject') ?? '').trim(); while (subject.startsWith('[') && subject.includes(']')) subject = subject.slice(subject.indexOf(']') + 1).trimStart(); subject = subject.trimEnd(); const rest = lines.slice(end + 1), bodyEnd = rest.findIndex( (line) => line.trimEnd() === '---' || line.startsWith('diff --git '), ), body = rest .slice(0, bodyEnd < 0 ? rest.length : bodyEnd) .join('\n') .trim(); if (bodyEnd < 0) fail(400, 'InvalidPatch', 'mail patch contains no file changes'); const diffStart = rest.findIndex( (line, index) => index >= bodyEnd && line.startsWith('diff --git '), ); if (diffStart < 0) fail(400, 'InvalidPatch', 'mail patch contains no Git diff'); let content = rest.slice(diffStart).join('\n'); const signature = content.lastIndexOf('\n-- \n'); if (signature >= 0) content = content.slice(0, signature) + '\n'; const change = header('change-id'); return { patch: content, message: body ? subject + '\n\n' + body : subject, author: { name, email, date: header('date') ?? '' }, ...(change && /^[^\x00-\x20\x7f]+$/.test(change) && change.length <= 256 ? { changeId: change } : {}), }; }); } export function mailIdentityDate(date: string, now: number) { const seconds = Math.floor(Date.parse(date.trim()) / 1000); if (!Number.isSafeInteger(seconds)) return { seconds: now, offset: '+0000' }; if (date.trim().endsWith('Z')) return { seconds, offset: '+0000' }; const match = /(?:([+-])(\d{2}):?(\d{2})|\b([A-Z]{2,3}))\s*$/.exec( date.trim(), ); if (!match) return { seconds: now, offset: '+0000' }; if (match[1]) { if (Number(match[2]) > 23 || Number(match[3]) > 59) return { seconds: now, offset: '+0000' }; return { seconds, offset: match[1] + match[2] + match[3] }; } const zones: Record = { UT: '+0000', UTC: '+0000', GMT: '+0000', Z: '+0000', EST: '-0500', EDT: '-0400', CST: '-0600', CDT: '-0500', MST: '-0700', MDT: '-0600', PST: '-0800', PDT: '-0700', }; return zones[match[4]] ? { seconds, offset: zones[match[4]] } : { seconds: now, offset: '+0000' }; }