import { EgressError } from "./errors.js"; import { EgressPolicy, type ValidatedEndpoint } from "./policy.js"; interface UriOccurrence { start: number; end: number; value: string } /** Parse attribute boundaries rather than finding URI= inside other quoted values. */ function attributeUris(attributes: string, offset: number): UriOccurrence[] { const uris: UriOccurrence[] = []; if (!/^\s*[A-Z0-9-]+\s*=/.test(attributes)) return uris; let cursor = 0; const seen = new Set(); while (cursor < attributes.length) { const key = /^\s*([A-Z0-9-]+)\s*=\s*/.exec(attributes.slice(cursor)); if (!key) throw new EgressError("INVALID_PLAYLIST", "Malformed HLS attributes"); cursor += key[0].length; const quoted = attributes[cursor] === '"'; const start = cursor + (quoted ? 1 : 0); const end = quoted ? attributes.indexOf('"', start) : attributes.indexOf(",", start); const valueEnd = end < 0 && !quoted ? attributes.length : end; if (valueEnd < 0) throw new EgressError("INVALID_PLAYLIST", "Unterminated HLS attribute"); if (key[1] === "URI" || key[1] === "SERVER-URI") { if (!quoted || start === valueEnd || seen.has(key[1])) throw new EgressError("INVALID_PLAYLIST", "Invalid HLS URI attribute"); seen.add(key[1]); uris.push({ start: offset + start, end: offset + valueEnd, value: attributes.slice(start, valueEnd) }); } cursor = valueEnd + (quoted ? 1 : 0); while (/\s/.test(attributes[cursor] ?? "") && cursor < attributes.length) cursor++; if (cursor === attributes.length) break; if (attributes[cursor] !== "," || cursor + 1 === attributes.length) throw new EgressError("INVALID_PLAYLIST", "Invalid HLS attribute suffix"); cursor++; } return uris; } function playlistUris(playlist: string): { uris: UriOccurrence[]; master: boolean } { if (!/^#EXTM3U(?:\r?\n|$)/.test(playlist) || playlist.includes("\u0000")) throw new EgressError("INVALID_PLAYLIST", "Missing HLS header"); const uris: UriOccurrence[] = []; let offset = 0; let awaitingVariant = false; let variants = 0; let media = false; for (const line of playlist.split(/(?<=\n)/)) { const text = line.replace(/\r?\n$/, "").trim(); if (text && !text.startsWith("#")) { const start = offset + line.indexOf(text); uris.push({ start, end: start + text.length, value: text }); if (awaitingVariant) { variants++; awaitingVariant = false; } } else if (text.startsWith("#EXT")) { if (text.startsWith("#EXTINF:")) media = true; if (text.startsWith("#EXT-X-STREAM-INF:")) { if (awaitingVariant) throw new EgressError("INVALID_PLAYLIST", "Variant URI is missing"); awaitingVariant = true; } const colon = line.indexOf(":"); if (colon >= 0) { const attributes = line.slice(colon + 1).replace(/\r?\n$/, ""); const attributesFound = attributeUris(attributes, offset + colon + 1); uris.push(...attributesFound); if (text.startsWith("#EXT-X-I-FRAME-STREAM-INF:")) { if (!attributesFound.length) throw new EgressError("INVALID_PLAYLIST", "I-frame URI is missing"); variants++; } } } offset += line.length; } if (awaitingVariant) throw new EgressError("INVALID_PLAYLIST", "Variant URI is missing"); return { uris, master: variants > 0 && !media }; } async function resolveUris(uris: UriOccurrence[], base: string | URL, policy: EgressPolicy): Promise { const baseUrl = (await policy.validate(base)).url; const endpoints: ValidatedEndpoint[] = []; // Sequential validation bounds DNS work even for large playlists; shared URI pins within this parse. const cache = new Map(); for (const occurrence of uris) { if (!occurrence.value || occurrence.value.includes("{$") || /[\s\\\u0000-\u001f\u007f]/u.test(occurrence.value)) throw new EgressError("INVALID_PLAYLIST", "Invalid or unexpanded HLS URI"); let absolute: URL; try { absolute = new URL(occurrence.value, baseUrl); } catch { throw new EgressError("INVALID_PLAYLIST", "Invalid HLS URI"); } let endpoint = cache.get(absolute.href); if (!endpoint) { endpoint = await policy.validate(absolute); cache.set(absolute.href, endpoint); } endpoints.push(endpoint); } return endpoints; } /** Validates master variants and URI-bearing tags (renditions, keys, maps, etc.) with DNS pinning. */ export async function validateMasterPlaylist(playlist: string, base: string | URL, policy = new EgressPolicy()): Promise { const { uris, master } = playlistUris(playlist); if (!master) throw new EgressError("INVALID_PLAYLIST", "Expected an HLS master playlist"); return resolveUris(uris, base, policy); } /** Produces absolute, validated child URLs, preserving comments, attributes and newline style. */ export async function rewriteSegmentUris(playlist: string, base: string | URL, policy = new EgressPolicy()): Promise { const { uris } = playlistUris(playlist); const endpoints = await resolveUris(uris, base, policy); let rewritten = playlist; for (let index = uris.length - 1; index >= 0; index--) { const uri = uris[index]!; rewritten = rewritten.slice(0, uri.start) + endpoints[index]!.url.href + rewritten.slice(uri.end); } return rewritten; }