@serverkgg/bridge 0.0.16 → 0.0.18
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.
- package/package.json +2 -2
- package/src/codecs/index.ts +1 -0
- package/src/codecs/javaProperties.test.ts +67 -0
- package/src/codecs/javaProperties.ts +99 -0
- package/src/context/bridgeContext.ts +8 -1
- package/src/core/bridgeAction.ts +2 -0
- package/src/modules/bridge.ts +2 -0
- package/src/modules/bridgeModule.ts +23 -6
- package/src/panel/bridgeCollection.ts +5 -0
- package/src/panel/index.ts +1 -0
- package/src/protocol/bridgeCatalog.ts +6 -0
- package/src/protocol/bridgeFrame.ts +13 -0
- package/src/protocol/bridgeManifest.ts +1 -0
- package/src/protocol/bridgePreview.ts +1 -0
- package/src/runtime/bridgeActions.ts +10 -0
- package/src/runtime/bridgeBackupLease.test.ts +148 -0
- package/src/runtime/bridgeBackupLease.ts +74 -0
- package/src/runtime/bridgeCodec.ts +12 -0
- package/src/runtime/bridgeFiles.test.ts +330 -4
- package/src/runtime/bridgeFiles.ts +112 -18
- package/src/runtime/bridgeRegistry.test.ts +61 -0
- package/src/runtime/bridgeRegistry.ts +31 -5
- package/src/runtime/bridgeServer.test.ts +57 -0
- package/src/runtime/bridgeServer.ts +14 -6
- package/src/runtime/bridgeZip.ts +73 -6
- package/src/runtime/serve.ts +48 -14
- package/src/wire/panel.ts +1 -0
|
@@ -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(
|
|
71
|
-
local.writeUInt32LE(
|
|
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 (
|
|
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 (
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
225
|
+
throw new BridgePathError("invalid", `could not list directory "${prefix}"`);
|
|
211
226
|
}
|
|
212
227
|
|
|
213
228
|
for (const entry of entries) {
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
555
|
-
const archive = await openZip(
|
|
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
|
|
606
|
-
await
|
|
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
|
+
});
|