@serverkgg/bridge 0.0.2 → 0.0.4

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,6 +1,38 @@
1
- import { BRIDGE_ID_PATTERN, BRIDGE_MODULE_ID_PATTERN, BridgeKind } from "../core";
1
+ import { BRIDGE_ID_PATTERN, BRIDGE_MODULE_ID_PATTERN, BridgeKind, type LocalizedText } from "../core";
2
2
  import type { BridgeDriver, BridgePanelModule } from "../modules";
3
- import { type BridgeField, BridgeFormTarget, BridgeLayout, type BridgeSection, type BridgeTab } from "../panel";
3
+ import {
4
+ type BridgeField,
5
+ BridgeFormTarget,
6
+ BridgeLayout,
7
+ type BridgeSection,
8
+ type BridgeSectionAction,
9
+ type BridgeTab,
10
+ type BridgeVisibleWhen,
11
+ } from "../panel";
12
+ import {
13
+ BRIDGE_ACTION_FIELDS_LIMIT,
14
+ BRIDGE_DEPENDS_ON_LIMIT,
15
+ BRIDGE_EVENTS_LIMIT,
16
+ BRIDGE_EXTENSION_LENGTH_LIMIT,
17
+ BRIDGE_FIELD_OPTIONS_LIMIT,
18
+ BRIDGE_FORM_FIELDS_LIMIT,
19
+ BRIDGE_ID_LENGTH_LIMIT,
20
+ BRIDGE_KEY_LENGTH_LIMIT,
21
+ BRIDGE_MODULE_ACTIONS_LIMIT,
22
+ BRIDGE_MODULES_LIMIT,
23
+ BRIDGE_OPTION_VALUE_LENGTH_LIMIT,
24
+ BRIDGE_PANEL_TABS_LIMIT,
25
+ BRIDGE_PLACEHOLDER_LENGTH_LIMIT,
26
+ BRIDGE_ROW_ACTIONS_LIMIT,
27
+ BRIDGE_SECTION_ACTIONS_LIMIT,
28
+ BRIDGE_STAGING_LENGTH_LIMIT,
29
+ BRIDGE_TAB_SECTIONS_LIMIT,
30
+ BRIDGE_TABLE_COLUMNS_LIMIT,
31
+ BRIDGE_TEXT_LENGTH_LIMIT,
32
+ BRIDGE_UPLOAD_EXTENSIONS_LIMIT,
33
+ BRIDGE_VISIBLE_VALUES_LIMIT,
34
+ } from "../protocol";
35
+ import { actionsOf } from "./bridgeActions";
4
36
 
5
37
  const RESERVED_MODULE_IDS = new Set([
6
38
  "announce",
@@ -23,16 +55,45 @@ const fail = (message: string): never => {
23
55
  throw new BridgeDriverError(message);
24
56
  };
25
57
 
58
+ const countIn = (count: number, limit: number, what: string, where: string) => {
59
+ if (count > limit) {
60
+ fail(`${where} declares ${count} ${what} — the platform takes at most ${limit}`);
61
+ }
62
+ };
63
+
64
+ const lengthIn = (value: string, limit: number, what: string, where: string) => {
65
+ if (value.length > limit) {
66
+ fail(`${where} ${what} is ${value.length} characters — the platform takes at most ${limit}`);
67
+ }
68
+ };
69
+
26
70
  const validateId = (id: string, where: string) => {
27
71
  if (!BRIDGE_ID_PATTERN.test(id)) {
28
72
  fail(`${where} id "${id}" must be kebab-case — lowercase letters, digits, and dashes, starting with a letter`);
29
73
  }
74
+
75
+ lengthIn(id, BRIDGE_ID_LENGTH_LIMIT, `id "${id}"`, where);
30
76
  };
31
77
 
32
78
  const validateModuleId = (id: string, where: string) => {
33
79
  if (!BRIDGE_MODULE_ID_PATTERN.test(id)) {
34
80
  fail(`${where} id "${id}" must be camelCase — letters and digits, starting with a lowercase letter`);
35
81
  }
82
+
83
+ lengthIn(id, BRIDGE_ID_LENGTH_LIMIT, `id "${id}"`, where);
84
+ };
85
+
86
+ const validateText = (text: LocalizedText | undefined, what: string, where: string) => {
87
+ if (!text) {
88
+ return;
89
+ }
90
+
91
+ if (text.ar.length === 0 || text.en.length === 0) {
92
+ fail(`${where} ${what} needs both arabic and english text`);
93
+ }
94
+
95
+ lengthIn(text.ar, BRIDGE_TEXT_LENGTH_LIMIT, `${what} arabic text`, where);
96
+ lengthIn(text.en, BRIDGE_TEXT_LENGTH_LIMIT, `${what} english text`, where);
36
97
  };
37
98
 
38
99
  const moduleOf = (modules: Record<string, BridgePanelModule>, id: string, where: string) => {
@@ -45,6 +106,18 @@ const moduleOf = (modules: Record<string, BridgePanelModule>, id: string, where:
45
106
  return module as BridgePanelModule;
46
107
  };
47
108
 
109
+ const validateVisibleWhen = (visibleWhen: BridgeVisibleWhen | undefined, where: string) => {
110
+ if (!visibleWhen) {
111
+ return;
112
+ }
113
+
114
+ if (visibleWhen.empty === undefined && (visibleWhen.values ?? []).length === 0) {
115
+ fail(`${where} is visible when "${visibleWhen.variable}" matches nothing — declare values, empty, or both`);
116
+ }
117
+
118
+ countIn((visibleWhen.values ?? []).length, BRIDGE_VISIBLE_VALUES_LIMIT, "visible values", where);
119
+ };
120
+
48
121
  const validateFields = (modules: Record<string, BridgePanelModule>, fields: BridgeField[], where: string) => {
49
122
  const keys = new Set<string>();
50
123
 
@@ -55,18 +128,98 @@ const validateFields = (modules: Record<string, BridgePanelModule>, fields: Brid
55
128
 
56
129
  keys.add(field.key);
57
130
 
131
+ const at = `${where} field "${field.key}"`;
132
+
133
+ lengthIn(field.key, BRIDGE_KEY_LENGTH_LIMIT, `key "${field.key}"`, where);
134
+ validateText(field.label, "label", at);
135
+ validateText(field.help, "help", at);
136
+ validateText(field.warning, "warning", at);
137
+ validateVisibleWhen(field.visibleWhen, at);
138
+
139
+ if (field.placeholder) {
140
+ lengthIn(field.placeholder, BRIDGE_PLACEHOLDER_LENGTH_LIMIT, "placeholder", at);
141
+ }
142
+
143
+ if (Array.isArray(field.options)) {
144
+ countIn(field.options.length, BRIDGE_FIELD_OPTIONS_LIMIT, "options", at);
145
+
146
+ for (const option of field.options) {
147
+ lengthIn(option.value, BRIDGE_OPTION_VALUE_LENGTH_LIMIT, `option "${option.value}"`, at);
148
+ validateText(option.label, `option "${option.value}" label`, at);
149
+ validateText(option.help, `option "${option.value}" help`, at);
150
+ }
151
+ }
152
+
58
153
  if (field.options && !Array.isArray(field.options)) {
59
- const options = moduleOf(modules, field.options.module, `${where} field "${field.key}"`);
154
+ const options = moduleOf(modules, field.options.module, at);
60
155
 
61
156
  if (options.kind !== BridgeKind.Options) {
62
- fail(`${where} field "${field.key}" references "${field.options.module}", which is not an options module`);
157
+ fail(`${at} references "${field.options.module}", which is not an options module`);
63
158
  }
64
159
  }
65
160
  }
66
161
  };
67
162
 
163
+ const validateActions = (modules: Record<string, BridgePanelModule>, actions: BridgeSectionAction[], where: string) => {
164
+ const ids = new Set<string>();
165
+
166
+ for (const action of actions) {
167
+ if (ids.has(action.id)) {
168
+ fail(`${where} declares action "${action.id}" more than once`);
169
+ }
170
+
171
+ ids.add(action.id);
172
+
173
+ const at = `${where} action "${action.id}"`;
174
+ const fields = action.fields ?? [];
175
+
176
+ lengthIn(action.id, BRIDGE_KEY_LENGTH_LIMIT, `id "${action.id}"`, where);
177
+ validateText(action.label, "label", at);
178
+ validateText(action.confirmText, "confirm text", at);
179
+
180
+ if (fields.length > BRIDGE_ACTION_FIELDS_LIMIT) {
181
+ fail(`${at} declares ${fields.length} fields — at most ${BRIDGE_ACTION_FIELDS_LIMIT} fit one prompt`);
182
+ }
183
+
184
+ validateFields(modules, fields, at);
185
+ }
186
+ };
187
+
68
188
  const validateSection = (modules: Record<string, BridgePanelModule>, section: BridgeSection, where: string) => {
189
+ validateVisibleWhen(section.visibleWhen, where);
190
+ validateText(section.title, "title", where);
191
+
192
+ if (section.layout !== BridgeLayout.Form) {
193
+ validateText(section.layout === BridgeLayout.Actions ? section.help : section.empty, "text", where);
194
+ }
195
+
69
196
  switch (section.layout) {
197
+ case BridgeLayout.Actions: {
198
+ const module = moduleOf(modules, section.module, where);
199
+
200
+ if (module.kind !== BridgeKind.Actions) {
201
+ fail(`${where} references "${section.module}", which is not an actions module`);
202
+
203
+ return;
204
+ }
205
+
206
+ if (section.actions.length === 0) {
207
+ fail(`${where} declares no actions`);
208
+ }
209
+
210
+ countIn(section.actions.length, BRIDGE_SECTION_ACTIONS_LIMIT, "actions", where);
211
+
212
+ validateActions(modules, section.actions, where);
213
+
214
+ for (const action of section.actions) {
215
+ if (!module.actions[action.id]) {
216
+ fail(`${where} declares action "${action.id}", which module "${section.module}" does not implement`);
217
+ }
218
+ }
219
+
220
+ return;
221
+ }
222
+
70
223
  case BridgeLayout.Form: {
71
224
  if (section.target === BridgeFormTarget.Settings && !section.module) {
72
225
  fail(`${where} targets settings but names no module`);
@@ -84,6 +237,13 @@ const validateSection = (modules: Record<string, BridgePanelModule>, section: Br
84
237
  }
85
238
  }
86
239
 
240
+ validateText(section.confirmText, "confirm text", where);
241
+
242
+ if (section.fields.length === 0) {
243
+ fail(`${where} declares no fields`);
244
+ }
245
+
246
+ countIn(section.fields.length, BRIDGE_FORM_FIELDS_LIMIT, "fields", where);
87
247
  validateFields(modules, section.fields, where);
88
248
 
89
249
  return;
@@ -99,11 +259,58 @@ const validateSection = (modules: Record<string, BridgePanelModule>, section: Br
99
259
  return;
100
260
  }
101
261
 
102
- if (section.layout === BridgeLayout.Table && section.add && !module.add) {
103
- fail(`${where} declares an add control but module "${section.module}" has no add handler`);
262
+ if (section.layout === BridgeLayout.Table) {
263
+ if (section.add && !module.add) {
264
+ fail(`${where} declares an add control but module "${section.module}" has no add handler`);
265
+ }
266
+
267
+ if (section.upload && !module.add) {
268
+ fail(`${where} declares an upload control but module "${section.module}" has no add handler`);
269
+ }
270
+
271
+ if (section.columns.length === 0) {
272
+ fail(`${where} declares no columns`);
273
+ }
274
+
275
+ countIn(section.columns.length, BRIDGE_TABLE_COLUMNS_LIMIT, "columns", where);
276
+
277
+ for (const column of section.columns) {
278
+ lengthIn(column.key, BRIDGE_KEY_LENGTH_LIMIT, `column key "${column.key}"`, where);
279
+ validateText(column.label, `column "${column.key}" label`, where);
280
+ }
281
+
282
+ if (section.add) {
283
+ validateText(section.add.label, "add label", where);
284
+
285
+ if (section.add.placeholder) {
286
+ lengthIn(section.add.placeholder, BRIDGE_PLACEHOLDER_LENGTH_LIMIT, "add placeholder", where);
287
+ }
288
+ }
289
+
290
+ if (section.upload) {
291
+ validateText(section.upload.label, "upload label", where);
292
+ lengthIn(section.upload.staging, BRIDGE_STAGING_LENGTH_LIMIT, "upload staging path", where);
293
+
294
+ if (section.upload.extensions.length === 0) {
295
+ fail(`${where} declares an upload control with no extensions`);
296
+ }
297
+
298
+ countIn(section.upload.extensions.length, BRIDGE_UPLOAD_EXTENSIONS_LIMIT, "upload extensions", where);
299
+
300
+ for (const extension of section.upload.extensions) {
301
+ lengthIn(extension, BRIDGE_EXTENSION_LENGTH_LIMIT, `upload extension "${extension}"`, where);
302
+ }
303
+ }
104
304
  }
105
305
 
306
+ countIn((section.actions ?? []).length, BRIDGE_ROW_ACTIONS_LIMIT, "actions", where);
307
+ validateActions(modules, section.actions ?? [], where);
308
+
106
309
  for (const action of section.actions ?? []) {
310
+ if (action.download) {
311
+ continue;
312
+ }
313
+
107
314
  if (!module.actions?.[action.id]) {
108
315
  fail(`${where} declares action "${action.id}", which module "${section.module}" does not implement`);
109
316
  }
@@ -119,6 +326,8 @@ const validateSection = (modules: Record<string, BridgePanelModule>, section: Br
119
326
  fail(`${where} references "${section.module}", which is not a catalog module`);
120
327
  }
121
328
 
329
+ validateText(section.confirmText, "confirm text", where);
330
+
122
331
  return;
123
332
  }
124
333
  }
@@ -128,11 +337,14 @@ const validateTab = (modules: Record<string, BridgePanelModule>, tab: BridgeTab)
128
337
  const sections = new Set<string>();
129
338
 
130
339
  validateId(tab.id, "tab");
340
+ validateText(tab.title, "title", `tab "${tab.id}"`);
131
341
 
132
342
  if (tab.sections.length === 0) {
133
343
  fail(`tab "${tab.id}" has no sections`);
134
344
  }
135
345
 
346
+ countIn(tab.sections.length, BRIDGE_TAB_SECTIONS_LIMIT, "sections", `tab "${tab.id}"`);
347
+
136
348
  for (const section of tab.sections) {
137
349
  if (sections.has(section.id)) {
138
350
  fail(`tab "${tab.id}" declares section "${section.id}" more than once`);
@@ -148,6 +360,15 @@ const validateTab = (modules: Record<string, BridgePanelModule>, tab: BridgeTab)
148
360
  export const validateDriver = (driver: BridgeDriver) => {
149
361
  const modules = driver.modules ?? {};
150
362
 
363
+ countIn(Object.keys(modules).length, BRIDGE_MODULES_LIMIT, "panel modules", "the driver");
364
+
365
+ const emitted = new Set([
366
+ ...(driver.events?.patterns ?? []).map((pattern) => pattern.emit),
367
+ ...(driver.events?.emits ?? []),
368
+ ]);
369
+
370
+ countIn(emitted.size, BRIDGE_EVENTS_LIMIT, "event names", "the driver");
371
+
151
372
  for (const [id, module] of Object.entries(modules)) {
152
373
  validateModuleId(id, "module");
153
374
 
@@ -155,6 +376,18 @@ export const validateDriver = (driver: BridgeDriver) => {
155
376
  fail(`module id "${id}" is reserved`);
156
377
  }
157
378
 
379
+ countIn(actionsOf(module).length, BRIDGE_MODULE_ACTIONS_LIMIT, "actions", `module "${id}"`);
380
+
381
+ if (module.kind === BridgeKind.Options) {
382
+ countIn((module.dependsOn ?? []).length, BRIDGE_DEPENDS_ON_LIMIT, "dependencies", `module "${id}"`);
383
+ }
384
+
385
+ if (module.kind === BridgeKind.Actions) {
386
+ for (const action of Object.keys(module.actions)) {
387
+ validateModuleId(action, `module "${id}" action`);
388
+ }
389
+ }
390
+
158
391
  if (module.kind === BridgeKind.Collection) {
159
392
  for (const action of Object.keys(module.actions ?? {})) {
160
393
  validateModuleId(action, `module "${id}" action`);
@@ -166,6 +399,12 @@ export const validateDriver = (driver: BridgeDriver) => {
166
399
  return;
167
400
  }
168
401
 
402
+ if (driver.panel.tabs.length === 0) {
403
+ fail("the panel declares no tabs");
404
+ }
405
+
406
+ countIn(driver.panel.tabs.length, BRIDGE_PANEL_TABS_LIMIT, "tabs", "the panel");
407
+
169
408
  const tabs = new Set<string>();
170
409
 
171
410
  for (const tab of driver.panel.tabs) {
@@ -51,7 +51,10 @@ export const createEventRing = (events: BridgeEventsModule | undefined, supervis
51
51
  return {
52
52
  names() {
53
53
  return [
54
- ...new Set((events?.patterns ?? []).map((pattern) => pattern.emit)),
54
+ ...new Set([
55
+ ...(events?.patterns ?? []).map((pattern) => pattern.emit),
56
+ ...(events?.emits ?? []),
57
+ ]),
55
58
  ];
56
59
  },
57
60
 
@@ -0,0 +1,334 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import type { BridgeFiles } from "../context";
5
+ import type { BridgeEgress } from "./bridgeEgress";
6
+ import { createBridgeFiles } from "./bridgeFiles";
7
+
8
+ const LOCAL_SIGNATURE = 0x04_03_4b_50;
9
+
10
+ const CENTRAL_SIGNATURE = 0x02_01_4b_50;
11
+
12
+ const EOCD_SIGNATURE = 0x06_05_4b_50;
13
+
14
+ const FILE_ATTRIBUTES = 0x81_a4_00_00;
15
+
16
+ const SYMLINK_ATTRIBUTES = 0xa1_ff_00_00;
17
+
18
+ interface ArchiveEntry {
19
+ name: string;
20
+ content: string;
21
+ symlink?: boolean;
22
+ }
23
+
24
+ const buildZip = (entries: ArchiveEntry[]) => {
25
+ const locals: Buffer[] = [];
26
+ const centrals: Buffer[] = [];
27
+
28
+ let offset = 0;
29
+
30
+ for (const entry of entries) {
31
+ const name = Buffer.from(entry.name, "utf8");
32
+ const data = Buffer.from(entry.content, "utf8");
33
+
34
+ const local = Buffer.alloc(30);
35
+
36
+ local.writeUInt32LE(LOCAL_SIGNATURE, 0);
37
+ local.writeUInt16LE(20, 4);
38
+ local.writeUInt32LE(data.length, 18);
39
+ local.writeUInt32LE(data.length, 22);
40
+ local.writeUInt16LE(name.length, 26);
41
+
42
+ const central = Buffer.alloc(46);
43
+
44
+ central.writeUInt32LE(CENTRAL_SIGNATURE, 0);
45
+ central.writeUInt16LE(20, 6);
46
+ central.writeUInt32LE(data.length, 20);
47
+ central.writeUInt32LE(data.length, 24);
48
+ central.writeUInt16LE(name.length, 28);
49
+ central.writeUInt32LE(entry.symlink === true ? SYMLINK_ATTRIBUTES : FILE_ATTRIBUTES, 38);
50
+ central.writeUInt32LE(offset, 42);
51
+
52
+ locals.push(local, name, data);
53
+ centrals.push(central, name);
54
+
55
+ offset += 30 + name.length + data.length;
56
+ }
57
+
58
+ const body = Buffer.concat(locals);
59
+ const directory = Buffer.concat(centrals);
60
+ const end = Buffer.alloc(22);
61
+
62
+ end.writeUInt32LE(EOCD_SIGNATURE, 0);
63
+ end.writeUInt16LE(entries.length, 8);
64
+ end.writeUInt16LE(entries.length, 10);
65
+ end.writeUInt32LE(directory.length, 12);
66
+ end.writeUInt32LE(body.length, 16);
67
+
68
+ return Buffer.concat([
69
+ body,
70
+ directory,
71
+ end,
72
+ ]);
73
+ };
74
+
75
+ const egress: BridgeEgress = {
76
+ fetch() {
77
+ throw new Error("the egress channel is not part of this test");
78
+ },
79
+
80
+ download() {
81
+ throw new Error("the egress channel is not part of this test");
82
+ },
83
+ };
84
+
85
+ describe("extract", () => {
86
+ let root = "";
87
+ let files: BridgeFiles;
88
+
89
+ const archive = async (entries: ArchiveEntry[]) => {
90
+ await Bun.write(`${root}/pack.zip`, buildZip(entries));
91
+
92
+ return "pack.zip";
93
+ };
94
+
95
+ const read = async (path: string) => {
96
+ return await Bun.file(`${root}/${path}`).text();
97
+ };
98
+
99
+ beforeEach(async () => {
100
+ root = await mkdtemp(`${tmpdir()}/serverk-bridge-`);
101
+
102
+ files = createBridgeFiles({
103
+ root,
104
+ uid: 0,
105
+ gid: 0,
106
+ egress,
107
+ onLog: () => undefined,
108
+ });
109
+ });
110
+
111
+ afterEach(async () => {
112
+ await rm(root, {
113
+ force: true,
114
+ recursive: true,
115
+ });
116
+ });
117
+
118
+ test("flattens paths, skips dotfiles and filters extensions by default", async () => {
119
+ const path = await archive([
120
+ {
121
+ name: "overrides/mods/alpha.jar",
122
+ content: "alpha",
123
+ },
124
+ {
125
+ name: "overrides/config/beta.json",
126
+ content: "beta",
127
+ },
128
+ {
129
+ name: ".hidden.jar",
130
+ content: "hidden",
131
+ },
132
+ ]);
133
+
134
+ const written = await files.extract(path, "mods", {
135
+ extensions: [
136
+ "jar",
137
+ ],
138
+ });
139
+
140
+ expect(written).toEqual([
141
+ "mods/alpha.jar",
142
+ ]);
143
+
144
+ expect(await read("mods/alpha.jar")).toBe("alpha");
145
+ });
146
+
147
+ test("flattens paths with no options at all", async () => {
148
+ const path = await archive([
149
+ {
150
+ name: "nested/deep/alpha.jar",
151
+ content: "alpha",
152
+ },
153
+ {
154
+ name: "beta.txt",
155
+ content: "beta",
156
+ },
157
+ ]);
158
+
159
+ const written = await files.extract(path, "plugins");
160
+
161
+ expect(written).toEqual([
162
+ "plugins/alpha.jar",
163
+ "plugins/beta.txt",
164
+ ]);
165
+ });
166
+
167
+ test("keeps relative paths and creates intermediate directories in tree mode", async () => {
168
+ const path = await archive([
169
+ {
170
+ name: "mods/alpha.jar",
171
+ content: "alpha",
172
+ },
173
+ {
174
+ name: "config/deep/beta.json",
175
+ content: "beta",
176
+ },
177
+ ]);
178
+
179
+ const written = await files.extract(path, "staging", {
180
+ tree: true,
181
+ });
182
+
183
+ expect(written).toEqual([
184
+ "staging/mods/alpha.jar",
185
+ "staging/config/deep/beta.json",
186
+ ]);
187
+
188
+ expect(await read("staging/mods/alpha.jar")).toBe("alpha");
189
+ expect(await read("staging/config/deep/beta.json")).toBe("beta");
190
+ });
191
+
192
+ test("keeps the dotfile skip in tree mode", async () => {
193
+ const path = await archive([
194
+ {
195
+ name: "mods/alpha.jar",
196
+ content: "alpha",
197
+ },
198
+ {
199
+ name: "mods/.secret.jar",
200
+ content: "secret",
201
+ },
202
+ {
203
+ name: ".git/config.json",
204
+ content: "git",
205
+ },
206
+ ]);
207
+
208
+ const written = await files.extract(path, "staging", {
209
+ tree: true,
210
+ });
211
+
212
+ expect(written).toEqual([
213
+ "staging/mods/alpha.jar",
214
+ ]);
215
+ });
216
+
217
+ test("rejects absolute entries in tree mode", async () => {
218
+ const path = await archive([
219
+ {
220
+ name: "/etc/passwd",
221
+ content: "root",
222
+ },
223
+ ]);
224
+
225
+ await expect(
226
+ files.extract(path, "staging", {
227
+ tree: true,
228
+ }),
229
+ ).rejects.toThrow("absolute path");
230
+ });
231
+
232
+ test("rejects escaping entries in tree mode", async () => {
233
+ const path = await archive([
234
+ {
235
+ name: "overrides/../../escape.json",
236
+ content: "escape",
237
+ },
238
+ ]);
239
+
240
+ await expect(
241
+ files.extract(path, "staging", {
242
+ tree: true,
243
+ }),
244
+ ).rejects.toThrow("is not allowed");
245
+ });
246
+
247
+ test("rejects symlink entries in tree mode", async () => {
248
+ const path = await archive([
249
+ {
250
+ name: "mods/link.jar",
251
+ content: "/etc/passwd",
252
+ symlink: true,
253
+ },
254
+ ]);
255
+
256
+ await expect(
257
+ files.extract(path, "staging", {
258
+ tree: true,
259
+ }),
260
+ ).rejects.toThrow("symlink");
261
+ });
262
+
263
+ test("extracts only the selected prefix and strips it", async () => {
264
+ const path = await archive([
265
+ {
266
+ name: "overrides/mods/alpha.jar",
267
+ content: "alpha",
268
+ },
269
+ {
270
+ name: "server-overrides/mods/beta.jar",
271
+ content: "beta",
272
+ },
273
+ {
274
+ name: "modrinth.index.json",
275
+ content: "{}",
276
+ },
277
+ ]);
278
+
279
+ const written = await files.extract(path, "", {
280
+ select: "overrides",
281
+ });
282
+
283
+ expect(written).toEqual([
284
+ "alpha.jar",
285
+ ]);
286
+
287
+ expect(await read("alpha.jar")).toBe("alpha");
288
+ });
289
+
290
+ test("composes select with tree", async () => {
291
+ const path = await archive([
292
+ {
293
+ name: "overrides/mods/alpha.jar",
294
+ content: "alpha",
295
+ },
296
+ {
297
+ name: "overrides/config/deep/beta.json",
298
+ content: "beta",
299
+ },
300
+ {
301
+ name: "modrinth.index.json",
302
+ content: "{}",
303
+ },
304
+ ]);
305
+
306
+ const written = await files.extract(path, "", {
307
+ select: "overrides/",
308
+ tree: true,
309
+ });
310
+
311
+ expect(written).toEqual([
312
+ "mods/alpha.jar",
313
+ "config/deep/beta.json",
314
+ ]);
315
+
316
+ expect(await read("config/deep/beta.json")).toBe("beta");
317
+ });
318
+
319
+ test("fails when nothing matches", async () => {
320
+ const path = await archive([
321
+ {
322
+ name: "overrides/mods/alpha.jar",
323
+ content: "alpha",
324
+ },
325
+ ]);
326
+
327
+ await expect(
328
+ files.extract(path, "staging", {
329
+ select: "server-overrides",
330
+ tree: true,
331
+ }),
332
+ ).rejects.toThrow("contained no matching files");
333
+ });
334
+ });