@serverkgg/bridge 0.0.16 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,6 +31,18 @@ const requiresRunning = (module: BridgePanelModule) => {
31
31
  return false;
32
32
  };
33
33
 
34
+ export const protectedActionsOf = (module: BridgePanelModule): string[] => {
35
+ const protectedActions = "protectedActions" in module ? (module.protectedActions ?? []) : [];
36
+ const declared = actionsOf(module);
37
+ if (
38
+ protectedActions.some((action) => isReadAction(action) || !declared.includes(action))
39
+ || (protectedActions.length > 0 && requiresRunning(module))
40
+ ) {
41
+ throw new BridgeDriverError("protected actions must be declared mutations that work while stopped");
42
+ }
43
+ return protectedActions;
44
+ };
45
+
34
46
  const refreshOf = (module: BridgePanelModule) => {
35
47
  if (module.kind === BridgeKind.Collection || module.kind === BridgeKind.Detail) {
36
48
  return module.refreshSeconds ?? null;
@@ -358,6 +370,12 @@ export const createRegistry = (
358
370
  }
359
371
 
360
372
  case BridgeKind.Catalog: {
373
+ if (action === BridgeAction.Releases && module.releases) {
374
+ return await module.releases(context, asString(args, "id"));
375
+ }
376
+ if (action === BridgeAction.Preview && module.preview) {
377
+ return await module.preview(context, asString(args, "id"), asOptional(args, "releaseId") ?? undefined);
378
+ }
361
379
  if (action === BridgeAction.Search) {
362
380
  return await module.search(context, {
363
381
  query: asText(args, "query"),
@@ -373,7 +391,7 @@ export const createRegistry = (
373
391
  }
374
392
 
375
393
  if (action === BridgeAction.Install) {
376
- return await module.install(context, asString(args, "id"));
394
+ return await module.install(context, asString(args, "id"), asOptional(args, "releaseId") ?? undefined);
377
395
  }
378
396
 
379
397
  if (action === BridgeAction.Remove) {
@@ -398,6 +416,7 @@ export const createRegistry = (
398
416
  return {
399
417
  manifest(): BridgeManifest {
400
418
  const entries: BridgeModuleManifest[] = Object.entries(modules).map(([id, module]) => {
419
+ const protectedActions = protectedActionsOf(module);
401
420
  return {
402
421
  id,
403
422
  kind: module.kind,
@@ -406,6 +425,11 @@ export const createRegistry = (
406
425
  pageSize: pageSizeOf(module),
407
426
  dependsOn: dependsOf(module),
408
427
  actions: actionsOf(module),
428
+ ...(protectedActions.length > 0
429
+ ? {
430
+ protectedActions,
431
+ }
432
+ : {}),
409
433
  };
410
434
  });
411
435
 
@@ -419,13 +443,17 @@ export const createRegistry = (
419
443
  };
420
444
  },
421
445
 
422
- async invoke(name: string, action: string, args: BridgeValues = {}) {
446
+ async invoke(name: string, action: string, args: BridgeValues = {}, recoveryOperationId?: string) {
423
447
  const module = modules[name];
424
448
 
425
449
  if (!module) {
426
450
  throw new BridgeDriverError(`no module named "${name}"`);
427
451
  }
428
452
 
453
+ if (protectedActionsOf(module).includes(action) && (!recoveryOperationId || supervisor.running)) {
454
+ throw new BridgeDriverError("this action needs a recovery operation with the game stopped");
455
+ }
456
+
429
457
  if (requiresRunning(module) && !supervisor.running) {
430
458
  throw new BridgeDriverError(`module "${name}" needs the game to be running`);
431
459
  }
@@ -597,3 +597,60 @@ describe("error serialisation", () => {
597
597
  });
598
598
  });
599
599
  });
600
+
601
+ describe("backup ownership dispatch", () => {
602
+ let dir = "";
603
+ beforeEach(async () => {
604
+ dir = await mkdtemp(join(tmpdir(), "bridge-lease-"));
605
+ });
606
+ afterEach(async () => {
607
+ await rm(dir, {
608
+ force: true,
609
+ recursive: true,
610
+ });
611
+ });
612
+ test("carries the lease token through renew and release frames", async () => {
613
+ const socket = join(dir, "bridge.sock");
614
+ const renewals: (string | undefined)[] = [];
615
+ const releases: (string | undefined)[] = [];
616
+ const server = createServer({
617
+ ...baseOptions(socket),
618
+ onQuiesce: async (supportsSnapshot, leaseId) => {
619
+ expect(supportsSnapshot).toBe(true);
620
+ renewals.push(leaseId);
621
+ return {
622
+ supported: true,
623
+ quiesced: true,
624
+ settleSeconds: 0,
625
+ leaseId,
626
+ };
627
+ },
628
+ onRelease: async (leaseId) => {
629
+ releases.push(leaseId);
630
+ },
631
+ });
632
+ try {
633
+ await call(socket, {
634
+ id: "renew",
635
+ token: TOKEN,
636
+ method: BridgeMethod.Quiesce,
637
+ supportsSnapshot: true,
638
+ leaseId: "owner",
639
+ });
640
+ await call(socket, {
641
+ id: "release",
642
+ token: TOKEN,
643
+ method: BridgeMethod.Release,
644
+ leaseId: "owner",
645
+ });
646
+ expect(renewals).toEqual([
647
+ "owner",
648
+ ]);
649
+ expect(releases).toEqual([
650
+ "owner",
651
+ ]);
652
+ } finally {
653
+ server.stop();
654
+ }
655
+ });
656
+ });
@@ -25,11 +25,11 @@ export interface ServerOptions {
25
25
  onStart: () => Promise<void>;
26
26
  onStop: () => Promise<void>;
27
27
  onCommand: (input: string, expect: string | undefined, timeoutMs: number | undefined) => Promise<BridgeCommandResult>;
28
- onInvoke: (module: string, action: string, args: BridgeValues) => Promise<unknown>;
28
+ onInvoke: (module: string, action: string, args: BridgeValues, recoveryOperationId?: string) => Promise<unknown>;
29
29
  onEvents: (cursor: number) => BridgeEventsResult;
30
30
  onSample: () => Promise<BridgeSampleResult>;
31
- onQuiesce: () => Promise<BridgeQuiesceResult>;
32
- onRelease: () => Promise<void>;
31
+ onQuiesce: (supportsSnapshot: boolean, leaseId?: string) => Promise<BridgeQuiesceResult>;
32
+ onRelease: (leaseId?: string) => Promise<void>;
33
33
  onDescribe: () => Promise<BridgeDescribeResult>;
34
34
  onAnnounce: (message: string) => Promise<BridgeAnnounceResult>;
35
35
  onPendingList: () => Promise<BridgePendingResult>;
@@ -40,6 +40,9 @@ export interface ServerOptions {
40
40
  }
41
41
 
42
42
  interface IncomingFrame {
43
+ leaseId?: unknown;
44
+ supportsSnapshot?: unknown;
45
+ recoveryOperationId?: unknown;
43
46
  id?: unknown;
44
47
  token?: unknown;
45
48
  method?: unknown;
@@ -116,7 +119,12 @@ export const createServer = (options: ServerOptions) => {
116
119
  }
117
120
 
118
121
  case BridgeMethod.Invoke: {
119
- return await options.onInvoke(String(request.module ?? ""), String(request.action ?? ""), asArgs(request.args));
122
+ return await options.onInvoke(
123
+ String(request.module ?? ""),
124
+ String(request.action ?? ""),
125
+ asArgs(request.args),
126
+ asText(request.recoveryOperationId) ?? undefined,
127
+ );
120
128
  }
121
129
 
122
130
  case BridgeMethod.Events: {
@@ -128,11 +136,11 @@ export const createServer = (options: ServerOptions) => {
128
136
  }
129
137
 
130
138
  case BridgeMethod.Quiesce: {
131
- return await options.onQuiesce();
139
+ return await options.onQuiesce(request.supportsSnapshot === true, asText(request.leaseId) ?? undefined);
132
140
  }
133
141
 
134
142
  case BridgeMethod.Release: {
135
- await options.onRelease();
143
+ await options.onRelease(asText(request.leaseId) ?? undefined);
136
144
 
137
145
  return null;
138
146
  }
@@ -1,4 +1,4 @@
1
- import { inflateRawSync } from "node:zlib";
1
+ import { crc32, inflateRawSync } from "node:zlib";
2
2
 
3
3
  const EOCD_SIGNATURE = 0x06_05_4b_50;
4
4
 
@@ -32,11 +32,14 @@ const DEFLATED = 8;
32
32
 
33
33
  const ENCRYPTED_FLAG = 0x1;
34
34
 
35
+ const DATA_DESCRIPTOR_FLAG = 0x8;
36
+
35
37
  export const ZIP_ENTRY_LIMIT = 2048;
36
38
 
37
39
  export const ZIP_TREE_ENTRY_LIMIT = 16_384;
38
40
 
39
41
  const MAX_ENTRY_BYTES = 128 * 1024 * 1024;
42
+ const MAX_CONFIGURED_ENTRY_BYTES = 512 * 1024 * 1024;
40
43
 
41
44
  const UNIX_MODE_FORMAT_MASK = 0xf0_00;
42
45
 
@@ -48,6 +51,8 @@ export interface ZipEntry {
48
51
  sizeBytes: number;
49
52
  compressedSize: number;
50
53
  method: number;
54
+ flags: number;
55
+ checksum: number;
51
56
  localOffset: number;
52
57
  }
53
58
 
@@ -155,7 +160,14 @@ const readZip64Extra = (extra: Buffer, sizeBytes: number, compressedSize: number
155
160
  return extended;
156
161
  };
157
162
 
158
- export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT): Promise<ZipArchive> => {
163
+ export const openZip = async (
164
+ path: string,
165
+ maxEntries: number = ZIP_ENTRY_LIMIT,
166
+ maxEntryBytes: number = MAX_ENTRY_BYTES,
167
+ ): Promise<ZipArchive> => {
168
+ if (!Number.isSafeInteger(maxEntryBytes) || maxEntryBytes <= 0 || maxEntryBytes > MAX_CONFIGURED_ENTRY_BYTES) {
169
+ throw new Error("archive entry byte limit is invalid");
170
+ }
159
171
  const size = Bun.file(path).size;
160
172
  const tailStart = Math.max(0, size - (EOCD_SEARCH_LIMIT + ZIP64_LOCATOR_LENGTH));
161
173
  const tail = await readRange(path, tailStart, size);
@@ -174,6 +186,9 @@ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT
174
186
  if (directory.total > maxEntries) {
175
187
  throw new Error(`archive has too many entries (${directory.total})`);
176
188
  }
189
+ if (directory.sizeBytes > 16 * 1024 * 1024 || directory.offset + directory.sizeBytes > size) {
190
+ throw new Error("archive central directory exceeds its bounds");
191
+ }
177
192
 
178
193
  const central = await readRange(path, directory.offset, directory.offset + directory.sizeBytes);
179
194
  const entries: ZipEntry[] = [];
@@ -187,6 +202,7 @@ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT
187
202
 
188
203
  const flags = central.readUInt16LE(cursor + 8);
189
204
  const method = central.readUInt16LE(cursor + 10);
205
+ const checksum = central.readUInt32LE(cursor + 16);
190
206
  const compressedSize = central.readUInt32LE(cursor + 20);
191
207
  const sizeBytes = central.readUInt32LE(cursor + 24);
192
208
  const nameLength = central.readUInt16LE(cursor + 28);
@@ -200,8 +216,14 @@ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT
200
216
  const extended = readZip64Extra(extra, sizeBytes, compressedSize, localOffset);
201
217
 
202
218
  cursor = nameStart + nameLength + extraLength + commentLength;
219
+ if (cursor > central.length) {
220
+ throw new Error("archive central entry exceeds its directory");
221
+ }
203
222
 
204
- if (name.endsWith("/") || (flags & ENCRYPTED_FLAG) !== 0) {
223
+ if ((flags & ENCRYPTED_FLAG) !== 0) {
224
+ throw new Error("encrypted archive entries are not supported");
225
+ }
226
+ if (name.endsWith("/")) {
205
227
  continue;
206
228
  }
207
229
 
@@ -211,6 +233,8 @@ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT
211
233
  sizeBytes: extended.sizeBytes ?? sizeBytes,
212
234
  compressedSize: extended.compressedSize ?? compressedSize,
213
235
  method,
236
+ flags,
237
+ checksum,
214
238
  localOffset: extended.localOffset ?? localOffset,
215
239
  });
216
240
  }
@@ -219,20 +243,59 @@ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT
219
243
  entries,
220
244
 
221
245
  async read(entry) {
222
- if (entry.sizeBytes > MAX_ENTRY_BYTES || entry.compressedSize > MAX_ENTRY_BYTES) {
246
+ if (entry.sizeBytes > maxEntryBytes || entry.compressedSize > maxEntryBytes) {
223
247
  throw new Error(`archive entry "${entry.name}" is too large`);
224
248
  }
225
249
 
250
+ if (entry.localOffset + LOCAL_HEADER_LENGTH > directory.offset) {
251
+ throw new Error(`archive entry "${entry.name}" has an out-of-bounds local header`);
252
+ }
253
+
226
254
  const header = await readRange(path, entry.localOffset, entry.localOffset + LOCAL_HEADER_LENGTH);
227
255
 
228
256
  if (header.length < LOCAL_HEADER_LENGTH || header.readUInt32LE(0) !== LOCAL_SIGNATURE) {
229
257
  throw new Error(`archive entry "${entry.name}" has a corrupt local header`);
230
258
  }
231
259
 
232
- const dataOffset = entry.localOffset + LOCAL_HEADER_LENGTH + header.readUInt16LE(26) + header.readUInt16LE(28);
260
+ if (header.readUInt16LE(8) !== entry.method || header.readUInt16LE(6) !== entry.flags) {
261
+ throw new Error(`archive entry "${entry.name}" has inconsistent local compression flags or method`);
262
+ }
263
+ if ((entry.flags & DATA_DESCRIPTOR_FLAG) === 0 && header.readUInt32LE(14) !== entry.checksum) {
264
+ throw new Error(`archive entry "${entry.name}" has inconsistent local checksum`);
265
+ }
266
+ if ((entry.flags & DATA_DESCRIPTOR_FLAG) === 0) {
267
+ const localCompressed = header.readUInt32LE(18);
268
+ const localSize = header.readUInt32LE(22);
269
+ if (
270
+ (localCompressed !== SIZE_SENTINEL && localCompressed !== entry.compressedSize)
271
+ || (localSize !== SIZE_SENTINEL && localSize !== entry.sizeBytes)
272
+ ) {
273
+ throw new Error(`archive entry "${entry.name}" has inconsistent local sizes`);
274
+ }
275
+ }
276
+
277
+ const nameOffset = entry.localOffset + LOCAL_HEADER_LENGTH;
278
+ const nameLength = header.readUInt16LE(26);
279
+ const dataOffset = nameOffset + nameLength + header.readUInt16LE(28);
280
+ if (dataOffset > directory.offset || entry.compressedSize > directory.offset - dataOffset) {
281
+ throw new Error(`archive entry "${entry.name}" data exceeds its local bounds`);
282
+ }
283
+ const name = await readRange(path, nameOffset, nameOffset + nameLength);
284
+ if (name.toString("utf8") !== entry.name) {
285
+ throw new Error(`archive entry "${entry.name}" has an inconsistent local name`);
286
+ }
233
287
  const compressed = await readRange(path, dataOffset, dataOffset + entry.compressedSize);
288
+ if (compressed.length !== entry.compressedSize) {
289
+ throw new Error(`archive entry "${entry.name}" has truncated compressed data`);
290
+ }
234
291
 
235
292
  if (entry.method === STORED) {
293
+ if (compressed.length !== entry.sizeBytes || compressed.length !== entry.compressedSize) {
294
+ throw new Error(`archive entry "${entry.name}" does not match its declared size`);
295
+ }
296
+ if (crc32(compressed) !== entry.checksum) {
297
+ throw new Error(`archive entry "${entry.name}" failed its CRC32 checksum`);
298
+ }
236
299
  return compressed;
237
300
  }
238
301
 
@@ -241,13 +304,17 @@ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT
241
304
  }
242
305
 
243
306
  const data = inflateRawSync(compressed, {
244
- maxOutputLength: MAX_ENTRY_BYTES,
307
+ maxOutputLength: maxEntryBytes,
245
308
  });
246
309
 
247
310
  if (data.length !== entry.sizeBytes) {
248
311
  throw new Error(`archive entry "${entry.name}" did not inflate to its declared size`);
249
312
  }
250
313
 
314
+ if (crc32(data) !== entry.checksum) {
315
+ throw new Error(`archive entry "${entry.name}" failed its CRC32 checksum`);
316
+ }
317
+
251
318
  return data;
252
319
  },
253
320
  };
@@ -9,6 +9,7 @@ import {
9
9
  type BridgeSampleResult,
10
10
  type BridgeStatus,
11
11
  } from "../protocol";
12
+ import { createBackupLease } from "./bridgeBackupLease";
12
13
  import { configPath, readConfig, socketPath, statePath } from "./bridgeConfigReader";
13
14
  import { createConsoleHandler } from "./bridgeConsoleHandler";
14
15
  import { createContext } from "./bridgeContext";
@@ -120,6 +121,18 @@ export const serve = async (driver: BridgeDriver) => {
120
121
  });
121
122
 
122
123
  const registry = createRegistry(driver, context, supervisor, ring);
124
+ const backupLease = createBackupLease(async () => {
125
+ if (supervisor.running) {
126
+ await driver.backup?.release?.(context);
127
+ }
128
+ });
129
+ setInterval(() => {
130
+ void backupLease.expire().catch((error: unknown) => {
131
+ log.error("could not resume saves after the backup lease expired", {
132
+ error: errorText(error),
133
+ });
134
+ });
135
+ }, 10_000).unref();
123
136
 
124
137
  const runConsole = createConsoleHandler({
125
138
  driver,
@@ -229,8 +242,8 @@ export const serve = async (driver: BridgeDriver) => {
229
242
  timeoutMs,
230
243
  });
231
244
  },
232
- async onInvoke(module, action, args) {
233
- return await registry.invoke(module, action, args);
245
+ async onInvoke(module, action, args, recoveryOperationId) {
246
+ return await registry.invoke(module, action, args, recoveryOperationId);
234
247
  },
235
248
  onEvents(cursor) {
236
249
  return ring.drain(cursor);
@@ -238,7 +251,18 @@ export const serve = async (driver: BridgeDriver) => {
238
251
  async onSample() {
239
252
  return await sample();
240
253
  },
241
- async onQuiesce() {
254
+ async onQuiesce(supportsSnapshot, leaseId) {
255
+ if (leaseId) {
256
+ return {
257
+ supported: true,
258
+ quiesced: true,
259
+ settleSeconds: 0,
260
+ leaseId: backupLease.renew(leaseId),
261
+ };
262
+ }
263
+ if (backupLease.active) {
264
+ throw new Error("a backup already owns the save hold");
265
+ }
242
266
  const backup = driver.backup;
243
267
 
244
268
  if (!backup?.quiesce) {
@@ -257,19 +281,29 @@ export const serve = async (driver: BridgeDriver) => {
257
281
  };
258
282
  }
259
283
 
260
- await backup.quiesce(context);
261
-
262
- return {
263
- supported: true,
264
- quiesced: true,
265
- settleSeconds: backup.settleSeconds ?? 0,
266
- };
267
- },
268
- async onRelease() {
269
- if (supervisor.running) {
270
- await driver.backup?.release?.(context);
284
+ const quiesce = backup.quiesce;
285
+ const owner = backupLease.begin(!supportsSnapshot);
286
+ try {
287
+ const prepared = await backupLease.prepare(owner, () => quiesce(context));
288
+ if (prepared?.snapshot && !supportsSnapshot) {
289
+ throw new Error("this backup requires an agent that supports bounded snapshots");
290
+ }
291
+ backupLease.renew(owner);
292
+ return {
293
+ supported: true,
294
+ quiesced: true,
295
+ settleSeconds: backup.settleSeconds ?? 0,
296
+ snapshot: prepared?.snapshot,
297
+ leaseId: supportsSnapshot ? owner : undefined,
298
+ };
299
+ } catch (error) {
300
+ await backupLease.finish(owner);
301
+ throw error;
271
302
  }
272
303
  },
304
+ async onRelease(leaseId) {
305
+ await backupLease.finish(leaseId);
306
+ },
273
307
  async onDescribe() {
274
308
  const describe = driver.install.describe;
275
309
 
package/src/wire/panel.ts CHANGED
@@ -337,6 +337,7 @@ export const bridgeModuleManifestSchema = z.object({
337
337
  pageSize: z.int().positive().nullable().default(null),
338
338
  dependsOn: z.array(zKey).max(8),
339
339
  actions: z.array(z.string().min(1).max(64)).max(16),
340
+ protectedActions: z.array(z.string().min(1).max(64)).max(16).optional(),
340
341
  });
341
342
 
342
343
  export const bridgeManifestSchema: z.ZodType<BridgeManifest> = z.object({