@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,13 +1,13 @@
1
1
  import type { Dirent } from "node:fs";
2
2
  import { chown, mkdir, readdir, rename, rm, writeFile } from "node:fs/promises";
3
- import type { BridgeDownloadOptions, BridgeFileEntry, BridgeFiles } from "../context";
3
+ import type { BridgeDownloadOptions, BridgeExtractOptions, BridgeFileEntry, BridgeFiles } from "../context";
4
4
  import { compileGlob } from "../utils";
5
5
  import { type BridgeEgress, BridgeEgressCallError, BridgeEgressUnavailableError } from "./bridgeEgress";
6
6
  import type { BridgeLogger } from "./bridgeLog";
7
7
  import { USER_AGENT } from "./bridgeNet";
8
8
  import { BridgePathError, resolveWithin, toSegments } from "./bridgePath";
9
9
  import { isPrivileged } from "./bridgePrivilege";
10
- import { readZip } from "./bridgeZip";
10
+ import { readZip, ZIP_ENTRY_LIMIT, ZIP_TREE_ENTRY_LIMIT } from "./bridgeZip";
11
11
 
12
12
  const READ_LIMIT = 4 * 1024 * 1024;
13
13
 
@@ -93,6 +93,32 @@ const directorySize = async (path: string, depth: number): Promise<number> => {
93
93
  return total;
94
94
  };
95
95
 
96
+ const selectPrefix = (select: string | undefined) => {
97
+ if (select === undefined) {
98
+ return null;
99
+ }
100
+
101
+ const segments = toSegments(select);
102
+
103
+ return segments.length === 0 ? null : `${segments.join("/")}/`;
104
+ };
105
+
106
+ const selectedName = (name: string, prefix: string | null) => {
107
+ if (prefix === null) {
108
+ return name;
109
+ }
110
+
111
+ return name.startsWith(prefix) ? name.slice(prefix.length) : null;
112
+ };
113
+
114
+ const treeSegments = (name: string) => {
115
+ if (name.startsWith("/") || /^[A-Za-z]:/.test(name)) {
116
+ throw new BridgePathError("invalid", `archive entry "${name}" is an absolute path`);
117
+ }
118
+
119
+ return toSegments(name);
120
+ };
121
+
96
122
  const walk = async (directory: string, prefix: string, depth: number, out: BridgeFileEntry[]) => {
97
123
  if (depth > WALK_DEPTH || out.length >= WALK_LIMIT) {
98
124
  return;
@@ -403,7 +429,7 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
403
429
  });
404
430
  },
405
431
 
406
- async extract(path, destination, extensions) {
432
+ async extract(path, destination, extractOptions: BridgeExtractOptions = {}) {
407
433
  const resolved = await resolveWithin(options.root, path);
408
434
 
409
435
  if (!resolved.exists || resolved.isDirectory) {
@@ -414,16 +440,32 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
414
440
  throw new BridgePathError("invalid", `archive "${path}" is larger than the extract limit`);
415
441
  }
416
442
 
417
- const allowed = extensions?.map((extension) => extension.toLowerCase());
443
+ const tree = extractOptions.tree === true;
444
+ const allowed = extractOptions.extensions?.map((extension) => extension.toLowerCase());
445
+ const prefix = selectPrefix(extractOptions.select);
446
+ const base = toSegments(destination);
418
447
  const target = await ensureDirectory(destination);
419
448
  const buffer = Buffer.from(await Bun.file(resolved.target).arrayBuffer());
449
+ const directories = new Set<string>();
420
450
  const written: string[] = [];
421
451
 
422
- for (const entry of readZip(buffer)) {
423
- const name = entry.name.split("/").at(-1) ?? "";
452
+ for (const entry of readZip(buffer, tree ? ZIP_TREE_ENTRY_LIMIT : ZIP_ENTRY_LIMIT)) {
453
+ const selected = selectedName(entry.name, prefix);
454
+
455
+ if (selected === null) {
456
+ continue;
457
+ }
458
+
459
+ const segments = tree
460
+ ? treeSegments(selected)
461
+ : [
462
+ selected.split("/").at(-1) ?? "",
463
+ ];
464
+
465
+ const name = segments.at(-1) ?? "";
424
466
  const extension = name.split(".").at(-1)?.toLowerCase() ?? "";
425
467
 
426
- if (name.length === 0 || name.startsWith(".")) {
468
+ if (name.length === 0 || segments.some((segment) => segment.startsWith("."))) {
427
469
  continue;
428
470
  }
429
471
 
@@ -431,10 +473,30 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
431
473
  continue;
432
474
  }
433
475
 
434
- const relative = toSegments(destination).length === 0 ? name : `${toSegments(destination).join("/")}/${name}`;
476
+ if (tree && entry.symlink) {
477
+ throw new BridgePathError("invalid", `archive entry "${entry.name}" is a symlink`);
478
+ }
479
+
480
+ const parent = segments.slice(0, -1);
481
+
482
+ if (parent.length > 0 && !directories.has(parent.join("/"))) {
483
+ await ensureDirectory(
484
+ [
485
+ ...base,
486
+ ...parent,
487
+ ].join("/"),
488
+ );
489
+
490
+ directories.add(parent.join("/"));
491
+ }
492
+
493
+ const relative = [
494
+ ...base,
495
+ ...segments,
496
+ ].join("/");
435
497
 
436
- await Bun.write(`${target}/${name}`, entry.data);
437
- await own(`${target}/${name}`);
498
+ await Bun.write(`${target}/${segments.join("/")}`, entry.data);
499
+ await own(`${target}/${segments.join("/")}`);
438
500
 
439
501
  written.push(relative);
440
502
  }
@@ -0,0 +1,291 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { tmpdir } from "node:os";
3
+ import { BridgeAction, BridgeKind, type BridgeRow, type BridgeValues } from "../core";
4
+ import type { BridgeDriver } from "../modules";
5
+ import { type BridgeConfig, BridgeEventName } from "../protocol";
6
+ import { createContext } from "./bridgeContext";
7
+ import { createEventRing } from "./bridgeEvents";
8
+ import { log } from "./bridgeLog";
9
+ import { createRegistry } from "./bridgeRegistry";
10
+ import { createSupervisor } from "./bridgeSupervisor";
11
+
12
+ const config = (): BridgeConfig => ({
13
+ code: "abcdef",
14
+ token: "token",
15
+ cwd: `${tmpdir()}/serverk-registry-test`,
16
+ run: `${tmpdir()}/serverk-registry-test/run`,
17
+ ports: {},
18
+ memoryMb: 1024,
19
+ cpuPercent: 100,
20
+ gameUid: 1000,
21
+ gameGid: 1000,
22
+ variables: {},
23
+ secrets: {},
24
+ });
25
+
26
+ const baseDriver = (): BridgeDriver => ({
27
+ install: {
28
+ kind: BridgeKind.Install,
29
+ run: async () => {
30
+ return;
31
+ },
32
+ },
33
+ lifecycle: {
34
+ kind: BridgeKind.Lifecycle,
35
+ command: async () => [
36
+ "run",
37
+ ],
38
+ },
39
+ });
40
+
41
+ const registryFor = (driver: BridgeDriver) => {
42
+ const settings = config();
43
+
44
+ const supervisor = createSupervisor({
45
+ cwd: settings.cwd,
46
+ uid: settings.gameUid,
47
+ gid: settings.gameGid,
48
+ stopTimeoutSeconds: 1,
49
+ onLog: log,
50
+ resolveCommand: async () => [
51
+ "run",
52
+ ],
53
+ });
54
+
55
+ const ring = createEventRing(driver.events, supervisor);
56
+
57
+ const context = createContext(settings, supervisor, {
58
+ onLog: log,
59
+ onEmit(event, payload) {
60
+ ring.emit(event, payload);
61
+ },
62
+ });
63
+
64
+ return createRegistry(driver, context, supervisor, ring);
65
+ };
66
+
67
+ describe("actions modules", () => {
68
+ test("lists its action ids in the manifest", () => {
69
+ const registry = registryFor({
70
+ ...baseDriver(),
71
+ modules: {
72
+ gameplay: {
73
+ kind: BridgeKind.Actions,
74
+ requiresRunning: true,
75
+ actions: {
76
+ timeDay: async () => {
77
+ return;
78
+ },
79
+ weatherClear: async () => {
80
+ return;
81
+ },
82
+ },
83
+ },
84
+ },
85
+ });
86
+
87
+ expect(registry.manifest().modules).toEqual([
88
+ {
89
+ id: "gameplay",
90
+ kind: BridgeKind.Actions,
91
+ requiresRunning: true,
92
+ refreshSeconds: null,
93
+ pageSize: null,
94
+ dependsOn: [],
95
+ actions: [
96
+ "timeDay",
97
+ "weatherClear",
98
+ ],
99
+ },
100
+ ]);
101
+ });
102
+
103
+ test("hands the whole argument bag to the handler", async () => {
104
+ const received: BridgeValues[] = [];
105
+
106
+ const registry = registryFor({
107
+ ...baseDriver(),
108
+ modules: {
109
+ broadcast: {
110
+ kind: BridgeKind.Actions,
111
+ actions: {
112
+ say: async (_context, args) => {
113
+ received.push(args);
114
+ },
115
+ },
116
+ },
117
+ },
118
+ });
119
+
120
+ await registry.invoke("broadcast", "say", {
121
+ message: "hello",
122
+ });
123
+
124
+ expect(received).toEqual([
125
+ {
126
+ message: "hello",
127
+ },
128
+ ]);
129
+ });
130
+
131
+ test("refuses to run while the game is stopped", async () => {
132
+ const registry = registryFor({
133
+ ...baseDriver(),
134
+ modules: {
135
+ broadcast: {
136
+ kind: BridgeKind.Actions,
137
+ requiresRunning: true,
138
+ actions: {
139
+ say: async () => {
140
+ return;
141
+ },
142
+ },
143
+ },
144
+ },
145
+ });
146
+
147
+ await expect(
148
+ registry.invoke("broadcast", "say", {
149
+ message: "hello",
150
+ }),
151
+ ).rejects.toThrow('module "broadcast" needs the game to be running');
152
+ });
153
+
154
+ test("rejects a driver that reuses a read action id", () => {
155
+ expect(() =>
156
+ registryFor({
157
+ ...baseDriver(),
158
+ modules: {
159
+ broadcast: {
160
+ kind: BridgeKind.Actions,
161
+ actions: {
162
+ list: async () => {
163
+ return;
164
+ },
165
+ },
166
+ },
167
+ },
168
+ }),
169
+ ).toThrow('module "broadcast" declares action "list", which is reserved for reads');
170
+ });
171
+ });
172
+
173
+ describe("manifest events", () => {
174
+ test("lists the log patterns and the names the driver raises itself", () => {
175
+ const registry = registryFor({
176
+ ...baseDriver(),
177
+ events: {
178
+ kind: BridgeKind.Events,
179
+ patterns: [
180
+ {
181
+ match: /Fatal error/,
182
+ emit: BridgeEventName.ServerCrashed,
183
+ },
184
+ {
185
+ match: /Fatal error again/,
186
+ emit: BridgeEventName.ServerCrashed,
187
+ },
188
+ ],
189
+ emits: [
190
+ BridgeEventName.PlayerJoined,
191
+ BridgeEventName.PlayerLeft,
192
+ BridgeEventName.ServerCrashed,
193
+ ],
194
+ },
195
+ });
196
+
197
+ expect(registry.manifest().events).toEqual([
198
+ BridgeEventName.ServerCrashed,
199
+ BridgeEventName.PlayerJoined,
200
+ BridgeEventName.PlayerLeft,
201
+ ]);
202
+ });
203
+
204
+ test("stays on the log patterns when the driver raises nothing itself", () => {
205
+ const registry = registryFor({
206
+ ...baseDriver(),
207
+ events: {
208
+ kind: BridgeKind.Events,
209
+ patterns: [
210
+ {
211
+ match: /Done/,
212
+ emit: BridgeEventName.ServerStarted,
213
+ },
214
+ ],
215
+ },
216
+ });
217
+
218
+ expect(registry.manifest().events).toEqual([
219
+ BridgeEventName.ServerStarted,
220
+ ]);
221
+ });
222
+ });
223
+
224
+ describe("collection row actions", () => {
225
+ test("passes the row and the arguments without the row id", async () => {
226
+ const seen: {
227
+ row: BridgeRow;
228
+ args: BridgeValues;
229
+ }[] = [];
230
+
231
+ const registry = registryFor({
232
+ ...baseDriver(),
233
+ modules: {
234
+ players: {
235
+ kind: BridgeKind.Collection,
236
+ list: async () => [
237
+ {
238
+ id: "meslzy",
239
+ },
240
+ ],
241
+ actions: {
242
+ xp: async (_context, row, args) => {
243
+ seen.push({
244
+ row,
245
+ args,
246
+ });
247
+ },
248
+ },
249
+ },
250
+ },
251
+ });
252
+
253
+ await registry.invoke("players", "xp", {
254
+ id: "meslzy",
255
+ amount: 30,
256
+ });
257
+
258
+ expect(seen).toEqual([
259
+ {
260
+ row: {
261
+ id: "meslzy",
262
+ },
263
+ args: {
264
+ amount: 30,
265
+ },
266
+ },
267
+ ]);
268
+ });
269
+
270
+ test("keeps listing the built-in actions beside the row actions", () => {
271
+ const registry = registryFor({
272
+ ...baseDriver(),
273
+ modules: {
274
+ players: {
275
+ kind: BridgeKind.Collection,
276
+ list: async () => [],
277
+ actions: {
278
+ kick: async () => {
279
+ return;
280
+ },
281
+ },
282
+ },
283
+ },
284
+ });
285
+
286
+ expect(registry.manifest().modules[0]?.actions).toEqual([
287
+ BridgeAction.List,
288
+ "kick",
289
+ ]);
290
+ });
291
+ });
@@ -1,5 +1,5 @@
1
1
  import type { BridgeContext } from "../context";
2
- import { BridgeAction, BridgeKind, type BridgeRow, type BridgeValues } from "../core";
2
+ import { BridgeAction, BridgeKind, type BridgeRow, type BridgeValues, isReadAction } from "../core";
3
3
  import type { BridgeDriver, BridgePanelModule, BridgeTerminalModule } from "../modules";
4
4
  import {
5
5
  BRIDGE_VERSION,
@@ -7,6 +7,7 @@ import {
7
7
  type BridgeModuleManifest,
8
8
  type BridgeTerminalManifest,
9
9
  } from "../protocol";
10
+ import { actionsOf } from "./bridgeActions";
10
11
  import { BridgeDriverError } from "./bridgeDriver";
11
12
  import type { BridgeEventRing } from "./bridgeEvents";
12
13
  import type { BridgeSupervisor } from "./bridgeSupervisor";
@@ -16,55 +17,12 @@ interface CacheEntry {
16
17
  expiresAt: number;
17
18
  }
18
19
 
19
- const actionsOf = (module: BridgePanelModule) => {
20
- switch (module.kind) {
21
- case BridgeKind.Options: {
22
- return [
23
- BridgeAction.List,
24
- ];
25
- }
26
-
27
- case BridgeKind.Settings: {
28
- return [
29
- BridgeAction.Read,
30
- ...(module.write
31
- ? [
32
- BridgeAction.Write,
33
- ]
34
- : []),
35
- ];
36
- }
37
-
38
- case BridgeKind.Collection: {
39
- return [
40
- BridgeAction.List,
41
- ...(module.add
42
- ? [
43
- BridgeAction.Add,
44
- ]
45
- : []),
46
- ...Object.keys(module.actions ?? {}),
47
- ];
48
- }
49
-
50
- case BridgeKind.Catalog: {
51
- return [
52
- BridgeAction.Install,
53
- BridgeAction.Installed,
54
- BridgeAction.Remove,
55
- BridgeAction.Search,
56
- ...(module.toggle
57
- ? [
58
- BridgeAction.Toggle,
59
- ]
60
- : []),
61
- ];
62
- }
20
+ const requiresRunning = (module: BridgePanelModule) => {
21
+ if (module.kind === BridgeKind.Actions || module.kind === BridgeKind.Collection) {
22
+ return module.requiresRunning ?? false;
63
23
  }
64
- };
65
24
 
66
- const requiresRunning = (module: BridgePanelModule) => {
67
- return module.kind === BridgeKind.Collection ? (module.requiresRunning ?? false) : false;
25
+ return false;
68
26
  };
69
27
 
70
28
  const refreshOf = (module: BridgePanelModule) => {
@@ -151,6 +109,18 @@ const asOptional = (args: BridgeValues, key: string) => {
151
109
  return typeof value === "string" && value.length > 0 ? value : null;
152
110
  };
153
111
 
112
+ const withoutId = (args: BridgeValues) => {
113
+ const rest: BridgeValues = {};
114
+
115
+ for (const [key, value] of Object.entries(args)) {
116
+ if (key !== "id") {
117
+ rest[key] = value;
118
+ }
119
+ }
120
+
121
+ return rest;
122
+ };
123
+
154
124
  const asPage = (args: BridgeValues) => {
155
125
  const value = args.page;
156
126
 
@@ -167,6 +137,20 @@ export const createRegistry = (
167
137
  const inflight = new Map<string, Promise<unknown>>();
168
138
  const modules = driver.modules ?? {};
169
139
 
140
+ for (const [id, module] of Object.entries(modules)) {
141
+ if (module.kind !== BridgeKind.Actions) {
142
+ continue;
143
+ }
144
+
145
+ for (const action of Object.keys(module.actions)) {
146
+ if (isReadAction(action)) {
147
+ throw new BridgeDriverError(
148
+ `module "${id}" declares action "${action}", which is reserved for reads — pick another id`,
149
+ );
150
+ }
151
+ }
152
+ }
153
+
170
154
  const cached = async (key: string, ttlSeconds: number | undefined, run: () => Promise<unknown>) => {
171
155
  if (!ttlSeconds) {
172
156
  return await run();
@@ -207,6 +191,18 @@ export const createRegistry = (
207
191
 
208
192
  const dispatch = async (module: BridgePanelModule, name: string, action: string, args: BridgeValues) => {
209
193
  switch (module.kind) {
194
+ case BridgeKind.Actions: {
195
+ const serverAction = module.actions[action];
196
+
197
+ if (serverAction) {
198
+ await serverAction(context, args);
199
+
200
+ return null;
201
+ }
202
+
203
+ break;
204
+ }
205
+
210
206
  case BridgeKind.Options: {
211
207
  if (action === BridgeAction.List) {
212
208
  const overlay: Record<string, string> = {};
@@ -268,7 +264,7 @@ export const createRegistry = (
268
264
  throw new BridgeDriverError(`module "${name}" has no row "${id}"`);
269
265
  }
270
266
 
271
- await rowAction(context, row as BridgeRow);
267
+ await rowAction(context, row as BridgeRow, withoutId(args));
272
268
 
273
269
  return null;
274
270
  }
@@ -352,5 +348,3 @@ export const createRegistry = (
352
348
  },
353
349
  };
354
350
  };
355
-
356
- export type BridgeRegistry = ReturnType<typeof createRegistry>;
@@ -1,5 +1,5 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
- import { BridgeFailureError, BridgeSecretError, type BridgeValues, type LocalizedText } from "../core";
2
+ import { BridgeFailureError, BridgeSecretError, type BridgeValues } from "../core";
3
3
  import {
4
4
  BRIDGE_FRAME_LIMIT,
5
5
  type BridgeAnnounceResult,
@@ -29,7 +29,7 @@ export interface ServerOptions {
29
29
  onQuiesce: () => Promise<BridgeQuiesceResult>;
30
30
  onRelease: () => Promise<void>;
31
31
  onDescribe: () => Promise<BridgeDescribeResult>;
32
- onAnnounce: (message: LocalizedText) => Promise<BridgeAnnounceResult>;
32
+ onAnnounce: (message: string) => Promise<BridgeAnnounceResult>;
33
33
  onLog: BridgeLogger;
34
34
  }
35
35
 
@@ -47,21 +47,8 @@ interface IncomingFrame {
47
47
  message?: unknown;
48
48
  }
49
49
 
50
- const asMessage = (value: unknown): LocalizedText | null => {
51
- if (value === null || typeof value !== "object") {
52
- return null;
53
- }
54
-
55
- const { ar, en } = value as Record<string, unknown>;
56
-
57
- if (typeof ar !== "string" || typeof en !== "string") {
58
- return null;
59
- }
60
-
61
- return {
62
- ar,
63
- en,
64
- };
50
+ const asMessage = (value: unknown): string | null => {
51
+ return typeof value === "string" && value.length > 0 ? value : null;
65
52
  };
66
53
 
67
54
  const matches = (left: string, right: string) => {
@@ -152,7 +139,7 @@ export const createServer = (options: ServerOptions) => {
152
139
  const message = asMessage(request.message);
153
140
 
154
141
  if (!message) {
155
- throw new Error("announce needs a message with ar and en text");
142
+ throw new Error("announce needs a message");
156
143
  }
157
144
 
158
145
  return await options.onAnnounce(message);
@@ -276,5 +263,3 @@ export const createServer = (options: ServerOptions) => {
276
263
  },
277
264
  });
278
265
  };
279
-
280
- export type BridgeSocketServer = ReturnType<typeof createServer>;
@@ -18,13 +18,20 @@ const DEFLATED = 8;
18
18
 
19
19
  const ENCRYPTED_FLAG = 0x1;
20
20
 
21
- const MAX_ENTRIES = 2048;
21
+ export const ZIP_ENTRY_LIMIT = 2048;
22
+
23
+ export const ZIP_TREE_ENTRY_LIMIT = 8192;
22
24
 
23
25
  const MAX_ENTRY_BYTES = 128 * 1024 * 1024;
24
26
 
27
+ const UNIX_MODE_FORMAT_MASK = 0xf0_00;
28
+
29
+ const UNIX_MODE_SYMLINK = 0xa0_00;
30
+
25
31
  export interface ZipEntry {
26
32
  name: string;
27
33
  data: Uint8Array;
34
+ symlink: boolean;
28
35
  }
29
36
 
30
37
  const findEndOfCentralDirectory = (buffer: Buffer) => {
@@ -39,7 +46,7 @@ const findEndOfCentralDirectory = (buffer: Buffer) => {
39
46
  return -1;
40
47
  };
41
48
 
42
- export const readZip = (buffer: Buffer): ZipEntry[] => {
49
+ export const readZip = (buffer: Buffer, maxEntries: number = ZIP_ENTRY_LIMIT): ZipEntry[] => {
43
50
  const eocd = findEndOfCentralDirectory(buffer);
44
51
 
45
52
  if (eocd === -1) {
@@ -49,7 +56,7 @@ export const readZip = (buffer: Buffer): ZipEntry[] => {
49
56
  const total = buffer.readUInt16LE(eocd + 10);
50
57
  const directoryOffset = buffer.readUInt32LE(eocd + 16);
51
58
 
52
- if (total > MAX_ENTRIES) {
59
+ if (total > maxEntries) {
53
60
  throw new Error(`archive has too many entries (${total})`);
54
61
  }
55
62
 
@@ -69,8 +76,10 @@ export const readZip = (buffer: Buffer): ZipEntry[] => {
69
76
  const nameLength = buffer.readUInt16LE(cursor + 28);
70
77
  const extraLength = buffer.readUInt16LE(cursor + 30);
71
78
  const commentLength = buffer.readUInt16LE(cursor + 32);
79
+ const externalAttributes = buffer.readUInt32LE(cursor + 38);
72
80
  const localOffset = buffer.readUInt32LE(cursor + 42);
73
81
  const name = buffer.subarray(cursor + 46, cursor + 46 + nameLength).toString("utf8");
82
+ const symlink = ((externalAttributes >>> 16) & UNIX_MODE_FORMAT_MASK) === UNIX_MODE_SYMLINK;
74
83
 
75
84
  cursor += 46 + nameLength + extraLength + commentLength;
76
85
 
@@ -95,6 +104,7 @@ export const readZip = (buffer: Buffer): ZipEntry[] => {
95
104
  entries.push({
96
105
  name,
97
106
  data: compressed,
107
+ symlink,
98
108
  });
99
109
 
100
110
  continue;
@@ -115,6 +125,7 @@ export const readZip = (buffer: Buffer): ZipEntry[] => {
115
125
  entries.push({
116
126
  name,
117
127
  data,
128
+ symlink,
118
129
  });
119
130
  }
120
131