@serverkgg/bridge 0.0.2 → 0.0.3
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 +1 -1
- package/src/cli/link.test.ts +199 -0
- package/src/cli/link.ts +319 -0
- package/src/cli/main.ts +8 -0
- package/src/cli/validate.test.ts +89 -0
- package/src/cli/validate.ts +100 -0
- package/src/context/bridgeContext.ts +7 -1
- package/src/core/bridgeKind.ts +1 -0
- package/src/modules/bridge.ts +6 -1
- package/src/modules/bridgeModule.ts +17 -3
- package/src/panel/bridgeLayout.ts +1 -0
- package/src/panel/bridgePanel.ts +34 -2
- package/src/protocol/bridgeCatalog.ts +1 -0
- package/src/protocol/bridgeEvents.ts +20 -0
- package/src/protocol/bridgeFrame.ts +2 -2
- package/src/protocol/bridgeLimits.ts +41 -0
- package/src/protocol/index.ts +2 -0
- package/src/runtime/bridgeActions.ts +53 -0
- package/src/runtime/bridgeDriver.test.ts +450 -0
- package/src/runtime/bridgeDriver.ts +245 -6
- package/src/runtime/bridgeEvents.ts +4 -1
- package/src/runtime/bridgeFiles.test.ts +334 -0
- package/src/runtime/bridgeFiles.ts +72 -10
- package/src/runtime/bridgeRegistry.test.ts +291 -0
- package/src/runtime/bridgeRegistry.ts +45 -51
- package/src/runtime/bridgeServer.ts +5 -20
- package/src/runtime/bridgeZip.ts +14 -3
|
@@ -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 {
|
|
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,
|
|
154
|
+
const options = moduleOf(modules, field.options.module, at);
|
|
60
155
|
|
|
61
156
|
if (options.kind !== BridgeKind.Options) {
|
|
62
|
-
fail(`${
|
|
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
|
|
103
|
-
|
|
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(
|
|
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
|
+
});
|