export interface UpdateManifestFile { readonly url: string; readonly sha512: string; readonly size: number; } export type UpdateManifestScalar = string | number | boolean; export interface UpdateManifest { readonly version: string; readonly releaseDate: string; readonly files: ReadonlyArray; readonly extras: Readonly>; } interface MutableUpdateManifestFile { url?: string; sha512?: string; size?: number; } function stripSingleQuotes(value: string): string { if (value.startsWith("'") && value.endsWith("'")) { return value.slice(1, -1).replace(/''/g, "'"); } return value; } function parseFileRecord( currentFile: MutableUpdateManifestFile | null, sourcePath: string, lineNumber: number, platformLabel: string, ): UpdateManifestFile | null { if (currentFile === null) { return null; } if ( typeof currentFile.url !== "string" || typeof currentFile.sha512 !== "string" || typeof currentFile.size !== "number" ) { throw new Error( `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: incomplete file entry.`, ); } return { url: currentFile.url, sha512: currentFile.sha512, size: currentFile.size, }; } function parseScalarValue(rawValue: string): UpdateManifestScalar { const trimmed = rawValue.trim(); const isQuoted = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2; const value = isQuoted ? trimmed.slice(1, -1).replace(/''/g, "'") : trimmed; if (isQuoted) return value; if (value === "true") return true; if (value === "false") return false; if (/^-?\d+(?:\.\d+)?$/.test(value)) { return Number(value); } return value; } export function parseUpdateManifest( raw: string, sourcePath: string, platformLabel: string, ): UpdateManifest { const lines = raw.split(/\r?\n/); const files: UpdateManifestFile[] = []; const extras: Record = {}; let version: string | null = null; let releaseDate: string | null = null; let inFiles = false; let currentFile: MutableUpdateManifestFile | null = null; for (const [index, rawLine] of lines.entries()) { const lineNumber = index + 1; const line = rawLine.trimEnd(); if (line.length === 0) continue; const fileUrlMatch = line.match(/^ - url:\s*(.+)$/); if (fileUrlMatch?.[1]) { const finalized = parseFileRecord(currentFile, sourcePath, lineNumber, platformLabel); if (finalized) files.push(finalized); currentFile = { url: stripSingleQuotes(fileUrlMatch[1].trim()) }; inFiles = true; continue; } const fileShaMatch = line.match(/^ sha512:\s*(.+)$/); if (fileShaMatch?.[1]) { if (currentFile === null) { throw new Error( `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: sha512 without a file entry.`, ); } currentFile.sha512 = stripSingleQuotes(fileShaMatch[1].trim()); continue; } const fileSizeMatch = line.match(/^ size:\s*(\d+)$/); if (fileSizeMatch?.[1]) { if (currentFile === null) { throw new Error( `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: size without a file entry.`, ); } currentFile.size = Number(fileSizeMatch[1]); continue; } if (line === "files:") { inFiles = true; continue; } if (inFiles && currentFile !== null) { const finalized = parseFileRecord(currentFile, sourcePath, lineNumber, platformLabel); if (finalized) files.push(finalized); currentFile = null; } inFiles = false; const topLevelMatch = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.+)$/); if (!topLevelMatch?.[1] || topLevelMatch[2] === undefined) { throw new Error( `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: unsupported line '${line}'.`, ); } const [, key, rawValue] = topLevelMatch; const value = parseScalarValue(rawValue); if (key === "version") { if (typeof value !== "string") { throw new Error( `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: version must be a string.`, ); } version = value; continue; } if (key === "releaseDate") { if (typeof value !== "string") { throw new Error( `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: releaseDate must be a string.`, ); } releaseDate = value; continue; } if (key === "path" || key === "sha512") { continue; } extras[key] = value; } const finalized = parseFileRecord(currentFile, sourcePath, lines.length, platformLabel); if (finalized) files.push(finalized); if (!version) { throw new Error(`Invalid ${platformLabel} update manifest at ${sourcePath}: missing version.`); } if (!releaseDate) { throw new Error( `Invalid ${platformLabel} update manifest at ${sourcePath}: missing releaseDate.`, ); } if (files.length === 0) { throw new Error(`Invalid ${platformLabel} update manifest at ${sourcePath}: missing files.`); } return { version, releaseDate, files, extras, }; } function mergeExtras( primary: Readonly>, secondary: Readonly>, platformLabel: string, ): Record { const merged: Record = { ...primary }; for (const [key, value] of Object.entries(secondary)) { const existing = merged[key]; if (existing !== undefined && existing !== value) { throw new Error( `Cannot merge ${platformLabel} update manifests: conflicting '${key}' values ('${existing}' vs '${value}').`, ); } merged[key] = value; } return merged; } export function mergeUpdateManifests( primary: UpdateManifest, secondary: UpdateManifest, platformLabel: string, ): UpdateManifest { if (primary.version !== secondary.version) { throw new Error( `Cannot merge ${platformLabel} update manifests with different versions (${primary.version} vs ${secondary.version}).`, ); } const filesByUrl = new Map(); for (const file of [...primary.files, ...secondary.files]) { const existing = filesByUrl.get(file.url); if (existing && (existing.sha512 !== file.sha512 || existing.size !== file.size)) { throw new Error( `Cannot merge ${platformLabel} update manifests: conflicting file entry for ${file.url}.`, ); } filesByUrl.set(file.url, file); } return { version: primary.version, releaseDate: primary.releaseDate >= secondary.releaseDate ? primary.releaseDate : secondary.releaseDate, files: [...filesByUrl.values()], extras: mergeExtras(primary.extras, secondary.extras, platformLabel), }; } function quoteYamlString(value: string): string { return `'${value.replace(/'/g, "''")}'`; } function serializeScalarValue(value: UpdateManifestScalar): string { if (typeof value === "string") { return quoteYamlString(value); } return String(value); } export function serializeUpdateManifest( manifest: UpdateManifest, options: { readonly platformLabel: string; }, ): string { const lines = [`version: ${quoteYamlString(manifest.version)}`, "files:"]; for (const file of manifest.files) { lines.push(` - url: ${file.url}`); lines.push(` sha512: ${file.sha512}`); lines.push(` size: ${file.size}`); } for (const key of Object.keys(manifest.extras).toSorted()) { const value = manifest.extras[key]; if (value === undefined) { throw new Error( `Cannot serialize ${options.platformLabel} update manifest: missing value for '${key}'.`, ); } lines.push(`${key}: ${serializeScalarValue(value)}`); } lines.push(`releaseDate: ${quoteYamlString(manifest.releaseDate)}`); lines.push(""); return lines.join("\n"); }