1export interface UpdateManifestFile {
2 readonly url: string;
3 readonly sha512: string;
4 readonly size: number;
5}
6
7export type UpdateManifestScalar = string | number | boolean;
8
9export interface UpdateManifest {
10 readonly version: string;
11 readonly releaseDate: string;
12 readonly files: ReadonlyArray<UpdateManifestFile>;
13 readonly extras: Readonly<Record<string, UpdateManifestScalar>>;
14}
15
16interface MutableUpdateManifestFile {
17 url?: string;
18 sha512?: string;
19 size?: number;
20}
21
22function stripSingleQuotes(value: string): string {
23 if (value.startsWith("'") && value.endsWith("'")) {
24 return value.slice(1, -1).replace(/''/g, "'");
25 }
26 return value;
27}
28
29function parseFileRecord(
30 currentFile: MutableUpdateManifestFile | null,
31 sourcePath: string,
32 lineNumber: number,
33 platformLabel: string,
34): UpdateManifestFile | null {
35 if (currentFile === null) {
36 return null;
37 }
38 if (
39 typeof currentFile.url !== "string" ||
40 typeof currentFile.sha512 !== "string" ||
41 typeof currentFile.size !== "number"
42 ) {
43 throw new Error(
44 `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: incomplete file entry.`,
45 );
46 }
47 return {
48 url: currentFile.url,
49 sha512: currentFile.sha512,
50 size: currentFile.size,
51 };
52}
53
54function parseScalarValue(rawValue: string): UpdateManifestScalar {
55 const trimmed = rawValue.trim();
56 const isQuoted = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2;
57 const value = isQuoted ? trimmed.slice(1, -1).replace(/''/g, "'") : trimmed;
58 if (isQuoted) return value;
59 if (value === "true") return true;
60 if (value === "false") return false;
61 if (/^-?\d+(?:\.\d+)?$/.test(value)) {
62 return Number(value);
63 }
64 return value;
65}
66
67export function parseUpdateManifest(
68 raw: string,
69 sourcePath: string,
70 platformLabel: string,
71): UpdateManifest {
72 const lines = raw.split(/\r?\n/);
73 const files: UpdateManifestFile[] = [];
74 const extras: Record<string, UpdateManifestScalar> = {};
75 let version: string | null = null;
76 let releaseDate: string | null = null;
77 let inFiles = false;
78 let currentFile: MutableUpdateManifestFile | null = null;
79
80 for (const [index, rawLine] of lines.entries()) {
81 const lineNumber = index + 1;
82 const line = rawLine.trimEnd();
83 if (line.length === 0) continue;
84
85 const fileUrlMatch = line.match(/^ - url:\s*(.+)$/);
86 if (fileUrlMatch?.[1]) {
87 const finalized = parseFileRecord(currentFile, sourcePath, lineNumber, platformLabel);
88 if (finalized) files.push(finalized);
89 currentFile = { url: stripSingleQuotes(fileUrlMatch[1].trim()) };
90 inFiles = true;
91 continue;
92 }
93
94 const fileShaMatch = line.match(/^ sha512:\s*(.+)$/);
95 if (fileShaMatch?.[1]) {
96 if (currentFile === null) {
97 throw new Error(
98 `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: sha512 without a file entry.`,
99 );
100 }
101 currentFile.sha512 = stripSingleQuotes(fileShaMatch[1].trim());
102 continue;
103 }
104
105 const fileSizeMatch = line.match(/^ size:\s*(\d+)$/);
106 if (fileSizeMatch?.[1]) {
107 if (currentFile === null) {
108 throw new Error(
109 `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: size without a file entry.`,
110 );
111 }
112 currentFile.size = Number(fileSizeMatch[1]);
113 continue;
114 }
115
116 if (currentFile !== null && /^ blockMapSize:\s*\d+$/.test(line)) continue;
117
118 if (line === "files:") {
119 inFiles = true;
120 continue;
121 }
122
123 if (inFiles && currentFile !== null) {
124 const finalized = parseFileRecord(currentFile, sourcePath, lineNumber, platformLabel);
125 if (finalized) files.push(finalized);
126 currentFile = null;
127 }
128 inFiles = false;
129
130 const topLevelMatch = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.+)$/);
131 if (!topLevelMatch?.[1] || topLevelMatch[2] === undefined) {
132 throw new Error(
133 `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: unsupported line '${line}'.`,
134 );
135 }
136
137 const [, key, rawValue] = topLevelMatch;
138 const value = parseScalarValue(rawValue);
139
140 if (key === "version") {
141 if (typeof value !== "string") {
142 throw new Error(
143 `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: version must be a string.`,
144 );
145 }
146 version = value;
147 continue;
148 }
149
150 if (key === "releaseDate") {
151 if (typeof value !== "string") {
152 throw new Error(
153 `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: releaseDate must be a string.`,
154 );
155 }
156 releaseDate = value;
157 continue;
158 }
159
160 if (key === "path" || key === "sha512") {
161 continue;
162 }
163
164 extras[key] = value;
165 }
166
167 const finalized = parseFileRecord(currentFile, sourcePath, lines.length, platformLabel);
168 if (finalized) files.push(finalized);
169
170 if (!version) {
171 throw new Error(`Invalid ${platformLabel} update manifest at ${sourcePath}: missing version.`);
172 }
173 if (!releaseDate) {
174 throw new Error(
175 `Invalid ${platformLabel} update manifest at ${sourcePath}: missing releaseDate.`,
176 );
177 }
178 if (files.length === 0) {
179 throw new Error(`Invalid ${platformLabel} update manifest at ${sourcePath}: missing files.`);
180 }
181
182 return {
183 version,
184 releaseDate,
185 files,
186 extras,
187 };
188}
189
190function mergeExtras(
191 primary: Readonly<Record<string, UpdateManifestScalar>>,
192 secondary: Readonly<Record<string, UpdateManifestScalar>>,
193 platformLabel: string,
194): Record<string, UpdateManifestScalar> {
195 const merged: Record<string, UpdateManifestScalar> = { ...primary };
196
197 for (const [key, value] of Object.entries(secondary)) {
198 const existing = merged[key];
199 if (existing !== undefined && existing !== value) {
200 throw new Error(
201 `Cannot merge ${platformLabel} update manifests: conflicting '${key}' values ('${existing}' vs '${value}').`,
202 );
203 }
204 merged[key] = value;
205 }
206
207 return merged;
208}
209
210export function mergeUpdateManifests(
211 primary: UpdateManifest,
212 secondary: UpdateManifest,
213 platformLabel: string,
214): UpdateManifest {
215 if (primary.version !== secondary.version) {
216 throw new Error(
217 `Cannot merge ${platformLabel} update manifests with different versions (${primary.version} vs ${secondary.version}).`,
218 );
219 }
220
221 const filesByUrl = new Map<string, UpdateManifestFile>();
222 for (const file of [...primary.files, ...secondary.files]) {
223 const existing = filesByUrl.get(file.url);
224 if (existing && (existing.sha512 !== file.sha512 || existing.size !== file.size)) {
225 throw new Error(
226 `Cannot merge ${platformLabel} update manifests: conflicting file entry for ${file.url}.`,
227 );
228 }
229 filesByUrl.set(file.url, file);
230 }
231
232 return {
233 version: primary.version,
234 releaseDate:
235 primary.releaseDate >= secondary.releaseDate ? primary.releaseDate : secondary.releaseDate,
236 files: [...filesByUrl.values()],
237 extras: mergeExtras(primary.extras, secondary.extras, platformLabel),
238 };
239}
240
241function quoteYamlString(value: string): string {
242 return `'${value.replace(/'/g, "''")}'`;
243}
244
245function serializeScalarValue(value: UpdateManifestScalar): string {
246 if (typeof value === "string") {
247 return quoteYamlString(value);
248 }
249 return String(value);
250}
251
252export function serializeUpdateManifest(
253 manifest: UpdateManifest,
254 options: {
255 readonly platformLabel: string;
256 },
257): string {
258 const lines = [`version: ${quoteYamlString(manifest.version)}`, "files:"];
259
260 for (const file of manifest.files) {
261 lines.push(` - url: ${file.url}`);
262 lines.push(` sha512: ${file.sha512}`);
263 lines.push(` size: ${file.size}`);
264 }
265
266 for (const key of Object.keys(manifest.extras).toSorted()) {
267 const value = manifest.extras[key];
268 if (value === undefined) {
269 throw new Error(
270 `Cannot serialize ${options.platformLabel} update manifest: missing value for '${key}'.`,
271 );
272 }
273 lines.push(`${key}: ${serializeScalarValue(value)}`);
274 }
275
276 lines.push(`releaseDate: ${quoteYamlString(manifest.releaseDate)}`);
277 lines.push("");
278 return lines.join("\n");
279}