@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.
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtemp, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
- import { deflateRawSync } from "node:zlib";
4
+ import { crc32, deflateRawSync } from "node:zlib";
5
5
  import type { BridgeFiles } from "../context";
6
6
  import { BridgeDownloadError } from "../core";
7
7
  import { type BridgeEgress, BridgeEgressCallError } from "./bridgeEgress";
@@ -47,10 +47,12 @@ interface ArchiveEntry {
47
47
 
48
48
  interface ArchiveOptions {
49
49
  zip64?: boolean;
50
+ descriptor?: boolean;
50
51
  }
51
52
 
52
53
  const buildZip = (entries: ArchiveEntry[], archiveOptions: ArchiveOptions = {}) => {
53
54
  const zip64 = archiveOptions.zip64 === true;
55
+ const descriptor = archiveOptions.descriptor === true;
54
56
  const locals: Buffer[] = [];
55
57
  const centrals: Buffer[] = [];
56
58
 
@@ -66,9 +68,11 @@ const buildZip = (entries: ArchiveEntry[], archiveOptions: ArchiveOptions = {})
66
68
 
67
69
  local.writeUInt32LE(LOCAL_SIGNATURE, 0);
68
70
  local.writeUInt16LE(20, 4);
71
+ local.writeUInt16LE(descriptor ? 8 : 0, 6);
69
72
  local.writeUInt16LE(method, 8);
70
- local.writeUInt32LE(data.length, 18);
71
- local.writeUInt32LE(plain.length, 22);
73
+ local.writeUInt32LE(descriptor ? 0 : crc32(plain), 14);
74
+ local.writeUInt32LE(descriptor ? 0 : data.length, 18);
75
+ local.writeUInt32LE(descriptor ? 0 : plain.length, 22);
72
76
  local.writeUInt16LE(name.length, 26);
73
77
 
74
78
  const extra = Buffer.alloc(zip64 ? ZIP64_EXTRA_LENGTH : 0);
@@ -85,7 +89,9 @@ const buildZip = (entries: ArchiveEntry[], archiveOptions: ArchiveOptions = {})
85
89
 
86
90
  central.writeUInt32LE(CENTRAL_SIGNATURE, 0);
87
91
  central.writeUInt16LE(20, 6);
92
+ central.writeUInt16LE(descriptor ? 8 : 0, 8);
88
93
  central.writeUInt16LE(method, 10);
94
+ central.writeUInt32LE(crc32(plain), 16);
89
95
  central.writeUInt32LE(zip64 ? SIZE_SENTINEL : data.length, 20);
90
96
  central.writeUInt32LE(zip64 ? SIZE_SENTINEL : plain.length, 24);
91
97
  central.writeUInt16LE(name.length, 28);
@@ -94,9 +100,17 @@ const buildZip = (entries: ArchiveEntry[], archiveOptions: ArchiveOptions = {})
94
100
  central.writeUInt32LE(zip64 ? SIZE_SENTINEL : offset, 42);
95
101
 
96
102
  locals.push(local, name, data);
103
+ if (descriptor) {
104
+ const trailer = Buffer.alloc(16);
105
+ trailer.writeUInt32LE(0x08_07_4b_50, 0);
106
+ trailer.writeUInt32LE(crc32(plain), 4);
107
+ trailer.writeUInt32LE(data.length, 8);
108
+ trailer.writeUInt32LE(plain.length, 12);
109
+ locals.push(trailer);
110
+ }
97
111
  centrals.push(central, name, extra);
98
112
 
99
- offset += 30 + name.length + data.length;
113
+ offset += 30 + name.length + data.length + (descriptor ? 16 : 0);
100
114
  }
101
115
 
102
116
  const body = Buffer.concat(locals);
@@ -220,6 +234,115 @@ describe("extract", () => {
220
234
  expect(await read("mods/alpha.jar")).toBe("alpha");
221
235
  });
222
236
 
237
+ test("preflights duplicate paths, parent collisions and expansion before writing", async () => {
238
+ for (const entries of [
239
+ [
240
+ {
241
+ name: "a.txt",
242
+ content: "a",
243
+ },
244
+ {
245
+ name: "a.txt",
246
+ content: "b",
247
+ },
248
+ ],
249
+ [
250
+ {
251
+ name: "a",
252
+ content: "a",
253
+ },
254
+ {
255
+ name: "a/b.txt",
256
+ content: "b",
257
+ },
258
+ ],
259
+ ]) {
260
+ const path = await archive(entries);
261
+ await expect(
262
+ files.extract(path, "staging", {
263
+ tree: true,
264
+ }),
265
+ ).rejects.toThrow();
266
+ expect(await Bun.file(`${root}/staging/a.txt`).exists()).toBe(false);
267
+ expect(await Bun.file(`${root}/staging/a`).exists()).toBe(false);
268
+ }
269
+ const path = await archive([
270
+ {
271
+ name: "a.txt",
272
+ content: "1234",
273
+ },
274
+ {
275
+ name: "b.txt",
276
+ content: "5678",
277
+ },
278
+ ]);
279
+ await expect(
280
+ files.extract(path, "staging", {
281
+ tree: true,
282
+ maxBytes: 7,
283
+ }),
284
+ ).rejects.toThrow("expanded byte limit");
285
+ expect(await Bun.file(`${root}/staging/a.txt`).exists()).toBe(false);
286
+ });
287
+
288
+ test("allows a bounded per-entry runtime limit without bypassing archive limits", async () => {
289
+ const path = await archive([
290
+ {
291
+ name: "runtime",
292
+ content: "12345678",
293
+ },
294
+ ]);
295
+ await expect(
296
+ files.extract(path, "staging", {
297
+ maxEntryBytes: 7,
298
+ }),
299
+ ).rejects.toThrow("too large");
300
+ expect(await Bun.file(`${root}/staging/runtime`).exists()).toBe(false);
301
+ await expect(
302
+ files.extract(path, "staging", {
303
+ maxEntryBytes: 8,
304
+ maxBytes: 7,
305
+ }),
306
+ ).rejects.toThrow("expanded byte limit");
307
+ for (const maxEntryBytes of [
308
+ 0,
309
+ -1,
310
+ 1.5,
311
+ Number.NaN,
312
+ 512 * 1024 * 1024 + 1,
313
+ ]) {
314
+ await expect(
315
+ files.extract(path, "staging", {
316
+ maxEntryBytes,
317
+ }),
318
+ ).rejects.toThrow("entry byte limit is invalid");
319
+ }
320
+ await files.extract(path, "staging", {
321
+ maxEntryBytes: 8,
322
+ });
323
+ expect(await read("staging/runtime")).toBe("12345678");
324
+ });
325
+
326
+ test("refuses unsafe unselected entries instead of accepting a partly safe archive", async () => {
327
+ const path = await archive([
328
+ {
329
+ name: "safe/a.txt",
330
+ content: "ok",
331
+ },
332
+ {
333
+ name: "../escape",
334
+ content: "bad",
335
+ },
336
+ ]);
337
+ await expect(
338
+ files.extract(path, "staging", {
339
+ tree: true,
340
+ select: "safe/",
341
+ }),
342
+ ).rejects.toThrow();
343
+ expect(await Bun.file(`${root}/staging/a.txt`).exists()).toBe(false);
344
+ });
345
+
223
346
  test("flattens paths with no options at all", async () => {
224
347
  const path = await archive([
225
348
  {
@@ -537,6 +660,122 @@ describe("extract", () => {
537
660
  expect(await read("mods/alpha.jar")).toBe("alpha the deflated");
538
661
  });
539
662
 
663
+ test("rejects stored data corruption before writing the destination", async () => {
664
+ const data = buildZip([
665
+ {
666
+ name: "alpha.jar",
667
+ content: "alpha",
668
+ },
669
+ ]);
670
+ data[30 + "alpha.jar".length] = 0x62;
671
+ await Bun.write(`${root}/corrupt.zip`, data);
672
+ await expect(files.extract("corrupt.zip", "mods")).rejects.toThrow("CRC32");
673
+ expect(await Bun.file(`${root}/mods/alpha.jar`).exists()).toBe(false);
674
+ });
675
+
676
+ test("rejects deflated data with a valid size but incorrect checksum", async () => {
677
+ const path = await archive([
678
+ {
679
+ name: "alpha.jar",
680
+ content: "alpha",
681
+ compressed: deflateRawSync(Buffer.from("bravo")),
682
+ },
683
+ ]);
684
+ await expect(files.extract(path, "mods")).rejects.toThrow("CRC32");
685
+ expect(await Bun.file(`${root}/mods/alpha.jar`).exists()).toBe(false);
686
+ });
687
+
688
+ test("rejects local and central compression method disagreement", async () => {
689
+ const data = buildZip([
690
+ {
691
+ name: "alpha.jar",
692
+ content: "alpha",
693
+ },
694
+ ]);
695
+ data.writeUInt16LE(DEFLATED, 8);
696
+ await Bun.write(`${root}/corrupt.zip`, data);
697
+ await expect(files.extract("corrupt.zip", "mods")).rejects.toThrow("method");
698
+ });
699
+
700
+ test("rejects local names that differ from the central directory", async () => {
701
+ const data = buildZip([
702
+ {
703
+ name: "alpha.jar",
704
+ content: "alpha",
705
+ },
706
+ ]);
707
+ data[30] = 0x62;
708
+ await Bun.write(`${root}/corrupt.zip`, data);
709
+ await expect(files.extract("corrupt.zip", "mods")).rejects.toThrow("local name");
710
+ });
711
+
712
+ test("rejects a local variable header that crosses into the central directory", async () => {
713
+ const data = buildZip([
714
+ {
715
+ name: "alpha.jar",
716
+ content: "alpha",
717
+ },
718
+ ]);
719
+ data.writeUInt16LE(65_535, 28);
720
+ await Bun.write(`${root}/corrupt.zip`, data);
721
+ await expect(files.extract("corrupt.zip", "mods")).rejects.toThrow("bounds");
722
+ });
723
+
724
+ test("rejects compressed data ranges that cross the central directory", async () => {
725
+ const data = buildZip([
726
+ {
727
+ name: "alpha.jar",
728
+ content: "alpha",
729
+ },
730
+ ]);
731
+ const central = 30 + "alpha.jar".length + "alpha".length;
732
+ data.writeUInt32LE(100, central + 20);
733
+ data.writeUInt32LE(100, 18);
734
+ await Bun.write(`${root}/corrupt.zip`, data);
735
+ await expect(files.extract("corrupt.zip", "mods")).rejects.toThrow("bounds");
736
+ });
737
+
738
+ test("accepts descriptor entries with zero local checksum and validates the central checksum", async () => {
739
+ const path = await archive(
740
+ [
741
+ {
742
+ name: "alpha.jar",
743
+ content: "alpha",
744
+ compressed: deflateRawSync(Buffer.from("alpha")),
745
+ },
746
+ ],
747
+ {
748
+ descriptor: true,
749
+ },
750
+ );
751
+ await files.extract(path, "mods");
752
+ expect(await read("mods/alpha.jar")).toBe("alpha");
753
+ });
754
+
755
+ test("rejects local checksum disagreement", async () => {
756
+ const data = buildZip([
757
+ {
758
+ name: "alpha.jar",
759
+ content: "alpha",
760
+ },
761
+ ]);
762
+ data.writeUInt32LE(0, 14);
763
+ await Bun.write(`${root}/corrupt.zip`, data);
764
+ await expect(files.extract("corrupt.zip", "mods")).rejects.toThrow("local checksum");
765
+ });
766
+
767
+ test("rejects local compressed-size disagreement", async () => {
768
+ const data = buildZip([
769
+ {
770
+ name: "alpha.jar",
771
+ content: "alpha",
772
+ },
773
+ ]);
774
+ data.writeUInt32LE(4, 18);
775
+ await Bun.write(`${root}/corrupt.zip`, data);
776
+ await expect(files.extract("corrupt.zip", "mods")).rejects.toThrow("local sizes");
777
+ });
778
+
540
779
  test("reads a zip64 archive through its locator and extended field", async () => {
541
780
  const path = await archive(
542
781
  [
@@ -677,6 +916,93 @@ describe("extract", () => {
677
916
  });
678
917
  });
679
918
 
919
+ describe("list", () => {
920
+ let root = "";
921
+ let files: BridgeFiles;
922
+ beforeEach(async () => {
923
+ root = await mkdtemp(`${tmpdir()}/serverk-bridge-list-`);
924
+ files = createBridgeFiles({
925
+ root,
926
+ uid: 0,
927
+ gid: 0,
928
+ egress,
929
+ onLog: silent,
930
+ });
931
+ });
932
+ afterEach(async () => {
933
+ await rm(root, {
934
+ force: true,
935
+ recursive: true,
936
+ });
937
+ });
938
+ test("filters before the result cap and scopes metadata beyond a large runtime tree", async () => {
939
+ for (let start = 0; start < 4097; start += 64) {
940
+ await Promise.all(
941
+ Array.from(
942
+ {
943
+ length: Math.min(64, 4097 - start),
944
+ },
945
+ (_, index) => files.write(`behavior_packs/vanilla/data-${start + index}.json`, "{}"),
946
+ ),
947
+ );
948
+ }
949
+ await files.write("behavior_packs/custom/manifest.json", "{}");
950
+ await files.write("worlds/[world]/world_behavior_packs.json", "[]");
951
+ expect((await files.list("**/manifest.json")).map((entry) => entry.path)).toEqual([
952
+ "behavior_packs/custom/manifest.json",
953
+ ]);
954
+ expect((await files.list("worlds/**")).map((entry) => entry.path)).toEqual([
955
+ "worlds/[world]/world_behavior_packs.json",
956
+ ]);
957
+ expect(
958
+ (
959
+ await files.list("**/*", {
960
+ directory: "worlds/[world]",
961
+ })
962
+ ).map((entry) => entry.path),
963
+ ).toEqual([
964
+ "worlds/[world]/world_behavior_packs.json",
965
+ ]);
966
+ await expect(files.list("**/*")).rejects.toThrow("result limit");
967
+ });
968
+ test("nonrecursive globs ignore unrelated deep trees and respect wildcard directory boundaries", async () => {
969
+ await files.write("server.jar", "jar");
970
+ await files.write("server.properties", "settings");
971
+ await files.write("mods/custom/plugin.jar", "plugin");
972
+ await files.write(`unrelated/${new Array(14).fill("nested").join("/")}/ignored.jar`, "unrelated");
973
+ await files.write(`mods/custom/unrelated/${new Array(14).fill("nested").join("/")}/ignored.jar`, "unrelated");
974
+ expect((await files.list("*")).map((entry) => entry.path).sort()).toEqual([
975
+ "server.jar",
976
+ "server.properties",
977
+ ]);
978
+ expect((await files.list("*.jar")).map((entry) => entry.path)).toEqual([
979
+ "server.jar",
980
+ ]);
981
+ expect((await files.list("mods/*/*.jar")).map((entry) => entry.path)).toEqual([
982
+ "mods/custom/plugin.jar",
983
+ ]);
984
+ await expect(files.list("**/*.jar")).rejects.toThrow("directory depth limit");
985
+ });
986
+ test("refuses partial deep listings and unsafe listing roots", async () => {
987
+ await files.write(
988
+ `${Array.from(
989
+ {
990
+ length: 14,
991
+ },
992
+ () => "nested",
993
+ ).join("/")}/file.txt`,
994
+ "data",
995
+ );
996
+ await expect(files.list("**/*")).rejects.toThrow("directory depth limit");
997
+ await expect(
998
+ files.list("**/*", {
999
+ directory: "../outside",
1000
+ }),
1001
+ ).rejects.toThrow();
1002
+ expect(await files.list("absent/**/*.json")).toEqual([]);
1003
+ });
1004
+ });
1005
+
680
1006
  describe("digest", () => {
681
1007
  let root = "";
682
1008
  let files: BridgeFiles;
@@ -27,6 +27,7 @@ const ARCHIVE_LIMIT = 2 * 1024 * 1024 * 1024;
27
27
  const WALK_DEPTH = 12;
28
28
 
29
29
  const WALK_LIMIT = 4096;
30
+ const WALK_SCAN_LIMIT = 100_000;
30
31
 
31
32
  const MILESTONE_STEP = 25;
32
33
 
@@ -188,16 +189,30 @@ const selectedName = (name: string, selects: ArchiveSelect[]) => {
188
189
  };
189
190
 
190
191
  const treeSegments = (name: string) => {
191
- if (name.startsWith("/") || /^[A-Za-z]:/.test(name)) {
192
+ if (
193
+ name.startsWith("/")
194
+ || /^[A-Za-z]:/.test(name)
195
+ || Array.from(name).some((character) => character.charCodeAt(0) < 32)
196
+ ) {
192
197
  throw new BridgePathError("invalid", `archive entry "${name}" is an absolute path`);
193
198
  }
194
199
 
195
200
  return toSegments(name);
196
201
  };
197
202
 
198
- const walk = async (directory: string, prefix: string, depth: number, out: BridgeFileEntry[]) => {
199
- if (depth > WALK_DEPTH || out.length >= WALK_LIMIT) {
200
- return;
203
+ const walk = async (
204
+ directory: string,
205
+ prefix: string,
206
+ depth: number,
207
+ out: BridgeFileEntry[],
208
+ pattern: RegExp,
209
+ directoryPatterns: RegExp[],
210
+ scan: {
211
+ visited: number;
212
+ },
213
+ ) => {
214
+ if (depth > WALK_DEPTH) {
215
+ throw new BridgePathError("invalid", "file listing exceeded its directory depth limit");
201
216
  }
202
217
 
203
218
  let entries: Dirent[];
@@ -207,12 +222,13 @@ const walk = async (directory: string, prefix: string, depth: number, out: Bridg
207
222
  withFileTypes: true,
208
223
  });
209
224
  } catch {
210
- return;
225
+ throw new BridgePathError("invalid", `could not list directory "${prefix}"`);
211
226
  }
212
227
 
213
228
  for (const entry of entries) {
214
- if (out.length >= WALK_LIMIT) {
215
- return;
229
+ scan.visited += 1;
230
+ if (scan.visited > WALK_SCAN_LIMIT) {
231
+ throw new BridgePathError("invalid", "file listing exceeded its scan limit");
216
232
  }
217
233
 
218
234
  if (entry.isSymbolicLink()) {
@@ -223,14 +239,19 @@ const walk = async (directory: string, prefix: string, depth: number, out: Bridg
223
239
  const absolute = `${directory}/${entry.name}`;
224
240
 
225
241
  if (entry.isDirectory()) {
226
- await walk(absolute, relative, depth + 1, out);
242
+ if (directoryPatterns.some((candidate) => candidate.test(relative))) {
243
+ await walk(absolute, relative, depth + 1, out, pattern, directoryPatterns, scan);
244
+ }
227
245
 
228
246
  continue;
229
247
  }
230
248
 
231
- if (!entry.isFile()) {
249
+ if (!entry.isFile() || !pattern.test(relative)) {
232
250
  continue;
233
251
  }
252
+ if (out.length >= WALK_LIMIT) {
253
+ throw new BridgePathError("invalid", "file listing exceeded its result limit");
254
+ }
234
255
 
235
256
  const file = Bun.file(absolute);
236
257
 
@@ -345,13 +366,36 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
345
366
  await own(resolved.target);
346
367
  },
347
368
 
348
- async list(glob) {
369
+ async list(glob, listOptions = {}) {
349
370
  const pattern = compileGlob(glob);
371
+ const globSegments = glob.split("/");
372
+ const directoryPatterns = globSegments.flatMap((segment, index) =>
373
+ index < globSegments.length - 1 || segment === "**"
374
+ ? [
375
+ compileGlob(globSegments.slice(0, index + 1).join("/")),
376
+ ]
377
+ : [],
378
+ );
350
379
  const entries: BridgeFileEntry[] = [];
351
-
352
- await walk(options.root, "", 0, entries);
353
-
354
- return entries.filter((entry) => pattern.test(entry.path));
380
+ const segments: string[] = [];
381
+ for (const segment of glob.split("/").slice(0, -1)) {
382
+ if (/[?*[\]{}!\\]/.test(segment)) {
383
+ break;
384
+ }
385
+ segments.push(segment);
386
+ }
387
+ const prefix = toSegments(listOptions.directory ?? segments.join("/")).join("/");
388
+ const resolved = await resolveWithin(options.root, prefix);
389
+ if (!resolved.exists) {
390
+ return [];
391
+ }
392
+ if (!resolved.isDirectory) {
393
+ throw new BridgePathError("invalid", `path "${prefix}" is not a directory to list`);
394
+ }
395
+ await walk(resolved.target, prefix, 0, entries, pattern, directoryPatterns, {
396
+ visited: 0,
397
+ });
398
+ return entries;
355
399
  },
356
400
 
357
401
  async remove(path) {
@@ -551,8 +595,57 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
551
595
  const selects = compileSelects(extractOptions.select, extractOptions.selects);
552
596
  const excluded = compileGlobs(extractOptions.exclude ?? []);
553
597
  const base = toSegments(destination);
554
- const target = await ensureDirectory(destination);
555
- const archive = await openZip(resolved.target, tree ? ZIP_TREE_ENTRY_LIMIT : ZIP_ENTRY_LIMIT);
598
+ await ensureDirectory(destination);
599
+ const archive = await openZip(
600
+ resolved.target,
601
+ tree ? ZIP_TREE_ENTRY_LIMIT : ZIP_ENTRY_LIMIT,
602
+ extractOptions.maxEntryBytes,
603
+ );
604
+ const maxBytes = extractOptions.maxBytes ?? ARCHIVE_LIMIT;
605
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0 || maxBytes > ARCHIVE_LIMIT) {
606
+ throw new BridgePathError("invalid", "archive expansion limit is invalid");
607
+ }
608
+ const destinations = new Set<string>();
609
+ let expandedBytes = 0;
610
+ for (const entry of archive.entries) {
611
+ treeSegments(entry.name);
612
+ if (entry.symlink) {
613
+ throw new BridgePathError("invalid", `archive entry "${entry.name}" is a symlink`);
614
+ }
615
+ expandedBytes += entry.sizeBytes;
616
+ if (!Number.isSafeInteger(expandedBytes) || expandedBytes > maxBytes) {
617
+ throw new BridgePathError("invalid", "archive exceeds the expanded byte limit");
618
+ }
619
+ const selected = selectedName(entry.name, selects);
620
+ if (selected === null || matchesAny(selected, excluded)) {
621
+ continue;
622
+ }
623
+ const segments = tree
624
+ ? treeSegments(selected)
625
+ : [
626
+ selected.split("/").at(-1) ?? "",
627
+ ];
628
+ const name = segments.at(-1) ?? "";
629
+ if (!name || segments.some((segment) => segment.startsWith("."))) {
630
+ continue;
631
+ }
632
+ if (allowed !== undefined && !allowed.includes(name.split(".").at(-1)?.toLowerCase() ?? "")) {
633
+ continue;
634
+ }
635
+ const destinationName = segments.join("/");
636
+ if (destinations.has(destinationName)) {
637
+ throw new BridgePathError("invalid", `archive has duplicate destination "${destinationName}"`);
638
+ }
639
+ destinations.add(destinationName);
640
+ }
641
+ for (const name of destinations) {
642
+ const segments = name.split("/");
643
+ for (let index = 1; index < segments.length; index++) {
644
+ if (destinations.has(segments.slice(0, index).join("/"))) {
645
+ throw new BridgePathError("invalid", `archive file "${name}" conflicts with a parent file`);
646
+ }
647
+ }
648
+ }
556
649
  const directories = new Set<string>();
557
650
  const written: string[] = [];
558
651
 
@@ -602,8 +695,9 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
602
695
  ...segments,
603
696
  ].join("/");
604
697
 
605
- await Bun.write(`${target}/${segments.join("/")}`, await archive.read(entry));
606
- await own(`${target}/${segments.join("/")}`);
698
+ const output = await resolveWithin(options.root, relative);
699
+ await Bun.write(output.target, await archive.read(entry));
700
+ await own(output.target);
607
701
 
608
702
  written.push(relative);
609
703
  }
@@ -693,3 +693,64 @@ describe("the setup singleton", () => {
693
693
  expect(registryFor(baseDriver()).manifest().setup).toBeNull();
694
694
  });
695
695
  });
696
+
697
+ describe("protected action dispatch", () => {
698
+ test("advertises protection and refuses old callers before touching files", async () => {
699
+ let calls = 0;
700
+ const registry = registryFor({
701
+ ...baseDriver(),
702
+ modules: {
703
+ world: {
704
+ kind: BridgeKind.Actions,
705
+ protectedActions: [
706
+ "replace",
707
+ ],
708
+ actions: {
709
+ replace: async () => {
710
+ calls += 1;
711
+ },
712
+ },
713
+ },
714
+ },
715
+ });
716
+ expect(registry.manifest().modules[0]?.protectedActions).toEqual([
717
+ "replace",
718
+ ]);
719
+ await expect(registry.invoke("world", "replace")).rejects.toThrow("needs a recovery operation");
720
+ expect(calls).toBe(0);
721
+ await registry.invoke("world", "replace", {}, "01993720-0000-7000-8000-000000000001");
722
+ expect(calls).toBe(1);
723
+ });
724
+ test("rejects protection metadata for unknown actions or running-only modules", () => {
725
+ for (const module of [
726
+ {
727
+ kind: BridgeKind.Actions as const,
728
+ protectedActions: [
729
+ "missing",
730
+ ],
731
+ actions: {
732
+ replace: async () => {},
733
+ },
734
+ },
735
+ {
736
+ kind: BridgeKind.Actions as const,
737
+ protectedActions: [
738
+ "replace",
739
+ ],
740
+ requiresRunning: true,
741
+ actions: {
742
+ replace: async () => {},
743
+ },
744
+ },
745
+ ]) {
746
+ expect(() =>
747
+ registryFor({
748
+ ...baseDriver(),
749
+ modules: {
750
+ world: module,
751
+ },
752
+ }).manifest(),
753
+ ).toThrow("protected actions");
754
+ }
755
+ });
756
+ });