@serverkgg/bridge 0.0.1 → 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.
Files changed (71) hide show
  1. package/package.json +10 -3
  2. package/src/cli/link.test.ts +199 -0
  3. package/src/cli/link.ts +319 -0
  4. package/src/cli/main.ts +19 -1
  5. package/src/cli/schema.ts +62 -0
  6. package/src/cli/validate.test.ts +89 -0
  7. package/src/cli/validate.ts +218 -35
  8. package/src/context/bridgeContext.ts +18 -2
  9. package/src/core/bridgeAction.ts +13 -0
  10. package/src/core/bridgeFailure.ts +16 -0
  11. package/src/core/bridgeKind.ts +3 -0
  12. package/src/core/bridgeNetError.ts +10 -0
  13. package/src/core/index.ts +2 -0
  14. package/src/guides/blocks.ts +167 -0
  15. package/src/guides/compile.ts +193 -0
  16. package/src/guides/compiled.ts +28 -0
  17. package/src/guides/index.ts +6 -0
  18. package/src/guides/markdown.test.ts +374 -0
  19. package/src/guides/markdown.ts +769 -0
  20. package/src/guides/variables.ts +52 -0
  21. package/src/guides/version.ts +1 -0
  22. package/src/main.ts +2 -1
  23. package/src/manifest/assets.ts +4 -4
  24. package/src/manifest/guide.ts +23 -18
  25. package/src/manifest/index.ts +3 -2
  26. package/src/manifest/jsonSchema.ts +16 -0
  27. package/src/manifest/localized.ts +7 -0
  28. package/src/manifest/manifest.test.ts +366 -0
  29. package/src/manifest/manifest.ts +354 -120
  30. package/src/manifest/primitives.ts +63 -0
  31. package/src/manifest/source.ts +5 -4
  32. package/src/modules/bridge.ts +25 -4
  33. package/src/modules/bridgeDriver.ts +4 -0
  34. package/src/modules/bridgeModule.ts +36 -24
  35. package/src/panel/bridgeAccess.ts +3 -0
  36. package/src/panel/bridgeLayout.ts +1 -0
  37. package/src/panel/bridgePanel.ts +40 -2
  38. package/src/panel/index.ts +1 -0
  39. package/src/protocol/bridgeCatalog.ts +57 -0
  40. package/src/protocol/bridgeEgress.ts +53 -0
  41. package/src/protocol/bridgeEvents.ts +20 -0
  42. package/src/protocol/bridgeFrame.ts +16 -0
  43. package/src/protocol/bridgeLimits.ts +41 -0
  44. package/src/protocol/bridgeLine.ts +41 -6
  45. package/src/protocol/bridgeManifest.ts +15 -1
  46. package/src/protocol/bridgeVersion.ts +1 -1
  47. package/src/protocol/index.ts +5 -0
  48. package/src/runtime/bridgeActions.ts +53 -0
  49. package/src/runtime/bridgeContext.ts +29 -9
  50. package/src/runtime/bridgeDriver.test.ts +450 -0
  51. package/src/runtime/bridgeDriver.ts +246 -6
  52. package/src/runtime/bridgeEgress.ts +360 -0
  53. package/src/runtime/bridgeEvents.ts +4 -1
  54. package/src/runtime/bridgeExec.ts +15 -5
  55. package/src/runtime/bridgeFiles.test.ts +334 -0
  56. package/src/runtime/bridgeFiles.ts +130 -31
  57. package/src/runtime/bridgeLog.ts +26 -3
  58. package/src/runtime/bridgeNet.ts +75 -22
  59. package/src/runtime/bridgeRegistry.test.ts +291 -0
  60. package/src/runtime/bridgeRegistry.ts +91 -52
  61. package/src/runtime/bridgeServer.ts +35 -3
  62. package/src/runtime/bridgeSupervisor.ts +6 -6
  63. package/src/runtime/bridgeZip.ts +14 -3
  64. package/src/runtime/index.ts +0 -17
  65. package/src/runtime/serve.ts +44 -3
  66. package/src/terminal/bridgeTerminalArg.ts +11 -0
  67. package/src/terminal/bridgeTerminalCommand.ts +11 -0
  68. package/src/terminal/bridgeTerminalLevel.ts +6 -0
  69. package/src/terminal/bridgeTerminalRule.ts +8 -0
  70. package/src/terminal/index.ts +4 -0
  71. package/src/manifest/checks.ts +0 -50
@@ -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
 
@@ -8,10 +8,20 @@ const OUTPUT_LIMIT = 256 * 1024;
8
8
 
9
9
  const EXIT_GRACE_MS = 3000;
10
10
 
11
- const BASE_ENVIRONMENT = {
12
- HOME: "/home/container",
13
- LANG: "C.UTF-8",
14
- PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
11
+ const FALLBACK_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
12
+
13
+ const inheritedPath = () => {
14
+ const declared = process.env.PATH ?? "";
15
+
16
+ return declared.length > 0 ? declared : FALLBACK_PATH;
17
+ };
18
+
19
+ const baseEnvironment = () => {
20
+ return {
21
+ HOME: "/home/container",
22
+ LANG: "C.UTF-8",
23
+ PATH: inheritedPath(),
24
+ };
15
25
  };
16
26
 
17
27
  export class BridgeExecError extends Error {
@@ -92,7 +102,7 @@ export const createBridgeExec = (target: BridgeExecTarget) => {
92
102
  const child = Bun.spawn(argv, {
93
103
  cwd: target.cwd,
94
104
  env: {
95
- ...BASE_ENVIRONMENT,
105
+ ...baseEnvironment(),
96
106
  HOME: target.cwd,
97
107
  ...environment,
98
108
  },
@@ -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
+ });
@@ -1,11 +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
+ import { type BridgeEgress, BridgeEgressCallError, BridgeEgressUnavailableError } from "./bridgeEgress";
5
6
  import type { BridgeLogger } from "./bridgeLog";
7
+ import { USER_AGENT } from "./bridgeNet";
6
8
  import { BridgePathError, resolveWithin, toSegments } from "./bridgePath";
7
9
  import { isPrivileged } from "./bridgePrivilege";
8
- import { readZip } from "./bridgeZip";
10
+ import { readZip, ZIP_ENTRY_LIMIT, ZIP_TREE_ENTRY_LIMIT } from "./bridgeZip";
9
11
 
10
12
  const READ_LIMIT = 4 * 1024 * 1024;
11
13
 
@@ -21,8 +23,6 @@ const WALK_LIMIT = 4096;
21
23
 
22
24
  const MILESTONE_STEP = 25;
23
25
 
24
- const USER_AGENT = "serverk-bridge/1.0 (serverk.gg)";
25
-
26
26
  const DIGEST_LENGTHS: Record<string, number> = {
27
27
  sha1: 40,
28
28
  sha256: 64,
@@ -33,9 +33,15 @@ export interface BridgeFilesOptions {
33
33
  root: string;
34
34
  uid: number;
35
35
  gid: number;
36
+ egress: BridgeEgress;
36
37
  onLog: BridgeLogger;
37
38
  }
38
39
 
40
+ interface DownloadSource {
41
+ body: AsyncIterable<Uint8Array>;
42
+ totalBytes: number | null;
43
+ }
44
+
39
45
  const parseDigest = (digest: string) => {
40
46
  const [algo, expected] = digest.split(":");
41
47
 
@@ -87,6 +93,32 @@ const directorySize = async (path: string, depth: number): Promise<number> => {
87
93
  return total;
88
94
  };
89
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
+
90
122
  const walk = async (directory: string, prefix: string, depth: number, out: BridgeFileEntry[]) => {
91
123
  if (depth > WALK_DEPTH || out.length >= WALK_LIMIT) {
92
124
  return;
@@ -169,6 +201,49 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
169
201
  await ensureDirectory(segments.slice(0, -1).join("/"));
170
202
  };
171
203
 
204
+ const openSource = async (remote: URL, downloadOptions: BridgeDownloadOptions): Promise<DownloadSource> => {
205
+ if (downloadOptions.cache !== false) {
206
+ try {
207
+ const shared = await options.egress.download(remote.href, {
208
+ digest: downloadOptions.digest,
209
+ sizeBytes: downloadOptions.sizeBytes,
210
+ });
211
+
212
+ return {
213
+ body: shared.body,
214
+ totalBytes: shared.sizeBytes > 0 ? shared.sizeBytes : (downloadOptions.sizeBytes ?? null),
215
+ };
216
+ } catch (error) {
217
+ if (error instanceof BridgeEgressCallError) {
218
+ throw new BridgePathError("missing", `${remote.host} answered ${error.status ?? error.message}`);
219
+ }
220
+
221
+ if (!(error instanceof BridgeEgressUnavailableError)) {
222
+ throw error;
223
+ }
224
+ }
225
+ }
226
+
227
+ const response = await fetch(remote, {
228
+ headers: {
229
+ "user-agent": USER_AGENT,
230
+ },
231
+ redirect: "error",
232
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
233
+ });
234
+
235
+ if (!response.ok || !response.body) {
236
+ throw new BridgePathError("missing", `${remote.host} answered ${response.status}`);
237
+ }
238
+
239
+ const declared = Number(response.headers.get("content-length"));
240
+
241
+ return {
242
+ body: response.body,
243
+ totalBytes: Number.isSafeInteger(declared) && declared > 0 ? declared : (downloadOptions.sizeBytes ?? null),
244
+ };
245
+ };
246
+
172
247
  return {
173
248
  async read(path) {
174
249
  const resolved = await resolveWithin(options.root, path);
@@ -262,7 +337,7 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
262
337
  const remote = new URL(url);
263
338
 
264
339
  if (remote.protocol !== "https:") {
265
- throw new BridgePathError("invalid", `url "${url}" is not https`);
340
+ throw new BridgePathError("invalid", `${remote.host} is not https`);
266
341
  }
267
342
 
268
343
  const limit = downloadOptions.sizeBytes ?? DOWNLOAD_LIMIT;
@@ -271,26 +346,14 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
271
346
  throw new BridgePathError("invalid", `download of ${limit} bytes exceeds the limit`);
272
347
  }
273
348
 
274
- const response = await fetch(url, {
275
- headers: {
276
- "user-agent": USER_AGENT,
277
- },
278
- redirect: "follow",
279
- signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
280
- });
281
-
282
- if (!response.ok || !response.body) {
283
- throw new BridgePathError("missing", `${url} answered ${response.status}`);
284
- }
349
+ const source = await openSource(remote, downloadOptions);
285
350
 
286
351
  await ensureParent(path);
287
352
 
288
353
  const resolved = await resolveWithin(options.root, path);
289
354
  const staging = `${resolved.target}.download`;
290
355
 
291
- const declared = Number(response.headers.get("content-length"));
292
- const totalBytes =
293
- Number.isSafeInteger(declared) && declared > 0 ? declared : (downloadOptions.sizeBytes ?? null);
356
+ const totalBytes = source.totalBytes;
294
357
 
295
358
  const hasher = downloadOptions.digest ? new Bun.CryptoHasher(parseDigest(downloadOptions.digest).algo) : null;
296
359
  const writer = Bun.file(staging).writer();
@@ -305,11 +368,11 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
305
368
  let milestone = 0;
306
369
 
307
370
  try {
308
- for await (const chunk of response.body) {
371
+ for await (const chunk of source.body) {
309
372
  readBytes += chunk.byteLength;
310
373
 
311
374
  if (readBytes > limit) {
312
- throw new BridgePathError("invalid", `${url} returned more than ${limit} bytes`);
375
+ throw new BridgePathError("invalid", `${remote.host} returned more than ${limit} bytes`);
313
376
  }
314
377
 
315
378
  hasher?.update(chunk);
@@ -336,7 +399,7 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
336
399
  if (downloadOptions.sizeBytes !== undefined && readBytes !== downloadOptions.sizeBytes) {
337
400
  throw new BridgePathError(
338
401
  "invalid",
339
- `${url} returned ${readBytes} bytes, expected ${downloadOptions.sizeBytes}`,
402
+ `${remote.host} returned ${readBytes} bytes, expected ${downloadOptions.sizeBytes}`,
340
403
  );
341
404
  }
342
405
 
@@ -344,7 +407,7 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
344
407
  const { expected } = parseDigest(downloadOptions.digest);
345
408
 
346
409
  if (hasher.digest("hex") !== expected) {
347
- throw new BridgePathError("invalid", `${url} failed digest verification`);
410
+ throw new BridgePathError("invalid", `${remote.host} failed digest verification`);
348
411
  }
349
412
  }
350
413
  } catch (error) {
@@ -366,7 +429,7 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
366
429
  });
367
430
  },
368
431
 
369
- async extract(path, destination, extensions) {
432
+ async extract(path, destination, extractOptions: BridgeExtractOptions = {}) {
370
433
  const resolved = await resolveWithin(options.root, path);
371
434
 
372
435
  if (!resolved.exists || resolved.isDirectory) {
@@ -377,16 +440,32 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
377
440
  throw new BridgePathError("invalid", `archive "${path}" is larger than the extract limit`);
378
441
  }
379
442
 
380
- 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);
381
447
  const target = await ensureDirectory(destination);
382
448
  const buffer = Buffer.from(await Bun.file(resolved.target).arrayBuffer());
449
+ const directories = new Set<string>();
383
450
  const written: string[] = [];
384
451
 
385
- for (const entry of readZip(buffer)) {
386
- 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) ?? "";
387
466
  const extension = name.split(".").at(-1)?.toLowerCase() ?? "";
388
467
 
389
- if (name.length === 0 || name.startsWith(".")) {
468
+ if (name.length === 0 || segments.some((segment) => segment.startsWith("."))) {
390
469
  continue;
391
470
  }
392
471
 
@@ -394,10 +473,30 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
394
473
  continue;
395
474
  }
396
475
 
397
- 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("/");
398
497
 
399
- await Bun.write(`${target}/${name}`, entry.data);
400
- await own(`${target}/${name}`);
498
+ await Bun.write(`${target}/${segments.join("/")}`, entry.data);
499
+ await own(`${target}/${segments.join("/")}`);
401
500
 
402
501
  written.push(relative);
403
502
  }
@@ -1,15 +1,38 @@
1
+ import { BRIDGE_LOG_SOURCE } from "../protocol/bridgeLine";
2
+ import { BridgeTerminalLevel } from "../terminal";
3
+
1
4
  export type BridgeLogger = (message: string, fields?: Record<string, unknown>) => void;
2
5
 
3
- export const log: BridgeLogger = (message, fields = {}) => {
6
+ export interface BridgeLeveledLogger extends BridgeLogger {
7
+ warn: BridgeLogger;
8
+ error: BridgeLogger;
9
+ }
10
+
11
+ const write = (level: BridgeTerminalLevel, message: string, fields: Record<string, unknown>) => {
4
12
  process.stdout.write(
5
13
  `${JSON.stringify({
6
- source: "bridge",
7
- message,
8
14
  ...fields,
15
+ source: BRIDGE_LOG_SOURCE,
16
+ level,
17
+ message,
9
18
  })}\n`,
10
19
  );
11
20
  };
12
21
 
22
+ export const log: BridgeLeveledLogger = Object.assign(
23
+ (message: string, fields: Record<string, unknown> = {}) => {
24
+ write(BridgeTerminalLevel.Info, message, fields);
25
+ },
26
+ {
27
+ warn: (message: string, fields: Record<string, unknown> = {}) => {
28
+ write(BridgeTerminalLevel.Warn, message, fields);
29
+ },
30
+ error: (message: string, fields: Record<string, unknown> = {}) => {
31
+ write(BridgeTerminalLevel.Error, message, fields);
32
+ },
33
+ },
34
+ );
35
+
13
36
  export const errorText = (error: unknown) => {
14
37
  return error instanceof Error ? error.message : String(error);
15
38
  };