@malloy-publisher/server 0.0.199 → 0.0.200

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 (58) hide show
  1. package/dist/app/api-doc.yaml +76 -111
  2. package/dist/app/assets/{EnvironmentPage-Dpee_Kn6.js → EnvironmentPage-CgKNjySu.js} +1 -1
  3. package/dist/app/assets/HomePage-BPIpMBjW.js +1 -0
  4. package/dist/app/assets/{MainPage-DsVt5QGM.js → MainPage-CAwb8U82.js} +2 -2
  5. package/dist/app/assets/{ModelPage-AwAugZ37.js → ModelPage-C0Uevsw9.js} +1 -1
  6. package/dist/app/assets/{PackagePage-XQ-EWGTC.js → PackagePage-Cu-u9k1g.js} +1 -1
  7. package/dist/app/assets/{RouteError-3Mv8JQw7.js → RouteError-DVwPh2Ql.js} +1 -1
  8. package/dist/app/assets/{WorkbookPage-DHYYpcYc.js → WorkbookPage-DW38R2Zv.js} +1 -1
  9. package/dist/app/assets/{core-DfcpQGVP.es-DQggNOdX.js → core-C0vCMRDQ.es-D_ytHhjS.js} +10 -10
  10. package/dist/app/assets/{index-D1pdwrUW.js → index-BGdcKsFF.js} +1 -1
  11. package/dist/app/assets/{index-BUp81Qdm.js → index-CTx4v4_3.js} +1 -1
  12. package/dist/app/assets/index-DE6d5jEy.js +452 -0
  13. package/dist/app/assets/{index.umd-CQH4LZU8.js → index.umd-C1Mi1uRm.js} +1 -1
  14. package/dist/app/index.html +1 -1
  15. package/dist/package_load_worker.mjs +1 -1
  16. package/dist/server.mjs +1482 -1010
  17. package/package.json +1 -1
  18. package/src/config.spec.ts +246 -0
  19. package/src/config.ts +121 -1
  20. package/src/constants.ts +84 -1
  21. package/src/controller/connection.controller.spec.ts +803 -0
  22. package/src/controller/connection.controller.ts +207 -20
  23. package/src/controller/model.controller.ts +16 -5
  24. package/src/controller/query.controller.ts +20 -7
  25. package/src/controller/watch-mode.controller.ts +11 -2
  26. package/src/errors.spec.ts +44 -0
  27. package/src/errors.ts +34 -0
  28. package/src/heap_check.spec.ts +144 -0
  29. package/src/heap_check.ts +144 -0
  30. package/src/mcp/handler_utils.ts +14 -0
  31. package/src/mcp/tools/execute_query_tool.ts +44 -14
  32. package/src/oom_guards.integration.spec.ts +261 -0
  33. package/src/path_safety.ts +9 -3
  34. package/src/query_cap_metrics.spec.ts +89 -0
  35. package/src/query_cap_metrics.ts +115 -0
  36. package/src/query_concurrency.spec.ts +247 -0
  37. package/src/query_concurrency.ts +236 -0
  38. package/src/query_timeout.spec.ts +224 -0
  39. package/src/query_timeout.ts +178 -0
  40. package/src/server-old.ts +20 -0
  41. package/src/server.ts +25 -47
  42. package/src/service/connection.ts +8 -2
  43. package/src/service/environment.ts +82 -2
  44. package/src/service/environment_admission.spec.ts +165 -1
  45. package/src/service/environment_store.spec.ts +103 -0
  46. package/src/service/environment_store.ts +74 -23
  47. package/src/service/model.spec.ts +193 -3
  48. package/src/service/model.ts +80 -12
  49. package/src/service/model_limits.spec.ts +181 -0
  50. package/src/service/model_limits.ts +110 -0
  51. package/src/service/package.spec.ts +2 -6
  52. package/src/service/package.ts +6 -1
  53. package/src/service/path_injection.spec.ts +39 -0
  54. package/src/stream_helpers.spec.ts +280 -0
  55. package/src/stream_helpers.ts +162 -0
  56. package/src/test_helpers/metrics_harness.ts +126 -0
  57. package/dist/app/assets/HomePage-DLRWTNoL.js +0 -1
  58. package/dist/app/assets/index-Dv5bF4Ii.js +0 -451
@@ -1,5 +1,6 @@
1
1
  import type { GivenValue, LogMessage } from "@malloydata/malloy";
2
2
  import { MalloyError, Runtime } from "@malloydata/malloy";
3
+ import { metrics } from "@opentelemetry/api";
3
4
  import { Mutex } from "async-mutex";
4
5
  import crypto from "crypto";
5
6
  import * as fs from "fs";
@@ -69,6 +70,49 @@ type RetiredConnectionGeneration = {
69
70
 
70
71
  const RETIRED_CONNECTION_DRAIN_MS = 30_000;
71
72
 
73
+ /**
74
+ * Module-scoped admission-rejection counters. Lazy-initialized so
75
+ * the OTel JS `ProxyMeter` cannot strand them on a NoOp instrument
76
+ * created before the SDK MeterProvider was registered (a real risk
77
+ * in unit tests; see comment in `query_timeout.ts`). Environment
78
+ * name is attached as a label so dashboards can identify hot
79
+ * environments without grepping logs.
80
+ */
81
+ import { type Counter } from "@opentelemetry/api";
82
+ let queryAdmissionRejectionsCounter: Counter | null = null;
83
+ let packageAdmissionRejectionsCounter: Counter | null = null;
84
+ function getQueryAdmissionRejectionsCounter(): Counter {
85
+ if (queryAdmissionRejectionsCounter) return queryAdmissionRejectionsCounter;
86
+ queryAdmissionRejectionsCounter = metrics
87
+ .getMeter("publisher")
88
+ .createCounter("publisher_query_admission_rejections_total", {
89
+ description:
90
+ "Queries rejected with 503 because Environment.assertCanAdmitQuery() observed memory back-pressure",
91
+ });
92
+ return queryAdmissionRejectionsCounter;
93
+ }
94
+ function getPackageAdmissionRejectionsCounter(): Counter {
95
+ if (packageAdmissionRejectionsCounter) {
96
+ return packageAdmissionRejectionsCounter;
97
+ }
98
+ packageAdmissionRejectionsCounter = metrics
99
+ .getMeter("publisher")
100
+ .createCounter("publisher_package_admission_rejections_total", {
101
+ description:
102
+ "Package loads rejected with 503 because Environment.assertCanAdmitNewPackage() observed memory back-pressure",
103
+ });
104
+ return packageAdmissionRejectionsCounter;
105
+ }
106
+
107
+ /**
108
+ * Visible for tests; production code never calls this. Resets the
109
+ * lazy caches so a fresh MeterProvider can capture future writes.
110
+ */
111
+ export function resetAdmissionTelemetryForTesting(): void {
112
+ queryAdmissionRejectionsCounter = null;
113
+ packageAdmissionRejectionsCounter = null;
114
+ }
115
+
72
116
  export class Environment {
73
117
  private packages: Map<string, Package> = new Map();
74
118
  // Lock ordering: connectionMutex (environment) MUST be acquired before any
@@ -176,6 +220,7 @@ export class Environment {
176
220
  environmentPath: string,
177
221
  connections: ApiConnection[],
178
222
  ): Promise<Environment> {
223
+ assertSafeEnvironmentPath(environmentPath);
179
224
  if (!(await fs.promises.stat(environmentPath))?.isDirectory()) {
180
225
  throw new EnvironmentNotFoundError(
181
226
  `Environment path ${environmentPath} not found`,
@@ -218,7 +263,7 @@ export class Environment {
218
263
  try {
219
264
  readme = (
220
265
  await fs.promises.readFile(
221
- path.join(this.environmentPath, README_NAME),
266
+ safeJoinUnderRoot(this.environmentPath, README_NAME),
222
267
  )
223
268
  ).toString();
224
269
  } catch {
@@ -579,8 +624,43 @@ export class Environment {
579
624
  ): void {
580
625
  if (allowAdmission) return;
581
626
  if (!this.memoryGovernor?.isBackpressured()) return;
627
+ // Increment *before* throwing so the metric ticks even on
628
+ // the not-uncommon "caught and swallowed" path. The label
629
+ // shape mirrors `assertCanAdmitQuery` so a dashboard panel
630
+ // can sum both rejection kinds by environment.
631
+ getPackageAdmissionRejectionsCounter().add(1, {
632
+ environment: this.environmentName,
633
+ reason,
634
+ });
635
+ throw new ServiceUnavailableError(
636
+ `Publisher is under memory pressure and cannot ${reason} (package "${packageName}", environment "${this.environmentName}"). Retry after the server's memory usage drops below the low-water mark (PUBLISHER_MEMORY_LOW_WATER_FRACTION of PUBLISHER_MAX_MEMORY_BYTES), or raise PUBLISHER_MAX_MEMORY_BYTES if you have headroom.`,
637
+ );
638
+ }
639
+
640
+ /**
641
+ * Reject incoming queries with HTTP 503 when the memory governor
642
+ * has tripped its high-water mark. Used by every query controller
643
+ * (connection SQL, model query, notebook cell, MCP `execute_query`)
644
+ * to shed load before the query runs — complementing
645
+ * {@link assertCanAdmitNewPackage}, which only fires on cache-miss
646
+ * package loads and so leaves already-loaded packages fully
647
+ * queryable under pressure. With this in place, "back-pressured"
648
+ * means "no new work of any kind" until the governor's low-water
649
+ * mark is crossed.
650
+ *
651
+ * Cheap O(1) boolean read; no allocation when happy.
652
+ */
653
+ public assertCanAdmitQuery(): void {
654
+ if (!this.memoryGovernor?.isBackpressured()) return;
655
+ // Tick first so the counter reflects every rejection even
656
+ // when the controller's catch block swallows the error (e.g.
657
+ // an MCP tool surfaces it as a content payload rather than
658
+ // letting it bubble to the HTTP error mapper).
659
+ getQueryAdmissionRejectionsCounter().add(1, {
660
+ environment: this.environmentName,
661
+ });
582
662
  throw new ServiceUnavailableError(
583
- `Publisher is under memory pressure and cannot ${reason} (package "${packageName}", environment "${this.environmentName}"). Retry after the server's memory usage drops below the configured low-water mark.`,
663
+ `Publisher is under memory pressure and cannot accept new queries (environment "${this.environmentName}"). Retry after the server's memory usage drops below the low-water mark (PUBLISHER_MEMORY_LOW_WATER_FRACTION of PUBLISHER_MAX_MEMORY_BYTES), or raise PUBLISHER_MAX_MEMORY_BYTES if you have headroom.`,
584
664
  );
585
665
  }
586
666
 
@@ -4,8 +4,12 @@ import * as os from "os";
4
4
  import * as path from "path";
5
5
 
6
6
  import { ServiceUnavailableError } from "../errors";
7
+ import {
8
+ startMetricsHarness,
9
+ type MetricsHarness,
10
+ } from "../test_helpers/metrics_harness";
7
11
  import { buildEnvironmentMalloyConfig } from "./connection";
8
- import { Environment } from "./environment";
12
+ import { Environment, resetAdmissionTelemetryForTesting } from "./environment";
9
13
  import type { PackageMemoryGovernor } from "./package_memory_governor";
10
14
 
11
15
  /**
@@ -178,3 +182,163 @@ describe("Environment admission gate (memory governor choke point)", () => {
178
182
  expect(caught).not.toBeInstanceOf(ServiceUnavailableError);
179
183
  });
180
184
  });
185
+
186
+ describe("Environment.assertCanAdmitQuery (query-path back-pressure)", () => {
187
+ let envDir: string;
188
+
189
+ beforeEach(() => {
190
+ envDir = fs.mkdtempSync(
191
+ path.join(os.tmpdir(), "publisher-env-query-admission-"),
192
+ );
193
+ });
194
+
195
+ afterEach(() => {
196
+ fs.rmSync(envDir, { recursive: true, force: true });
197
+ });
198
+
199
+ it("is a no-op when no governor is attached", () => {
200
+ const env = makeEnvironment(envDir);
201
+ // No governor — must not throw. Equivalent of an OSS / non-Docker
202
+ // deployment that never opted into PUBLISHER_MAX_MEMORY_BYTES.
203
+ expect(() => env.assertCanAdmitQuery()).not.toThrow();
204
+ });
205
+
206
+ it("is a no-op when the governor is happy (under high-water mark)", () => {
207
+ const env = makeEnvironment(envDir);
208
+ const governor = new StubGovernor();
209
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
210
+ governor.backpressured = false;
211
+
212
+ expect(() => env.assertCanAdmitQuery()).not.toThrow();
213
+ });
214
+
215
+ it("throws ServiceUnavailableError (→503) when back-pressured", () => {
216
+ const env = makeEnvironment(envDir);
217
+ const governor = new StubGovernor();
218
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
219
+ governor.backpressured = true;
220
+
221
+ expect(() => env.assertCanAdmitQuery()).toThrow(ServiceUnavailableError);
222
+ });
223
+
224
+ it("error message names the environment so operators can pinpoint the hot pod's load", () => {
225
+ const env = makeEnvironment(envDir);
226
+ const governor = new StubGovernor();
227
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
228
+ governor.backpressured = true;
229
+
230
+ let caught: unknown;
231
+ try {
232
+ env.assertCanAdmitQuery();
233
+ } catch (err) {
234
+ caught = err;
235
+ }
236
+ expect(caught).toBeInstanceOf(ServiceUnavailableError);
237
+ expect((caught as Error).message).toContain('environment "test-env"');
238
+ expect((caught as Error).message).toContain("memory pressure");
239
+ });
240
+
241
+ it("clearing back-pressure immediately re-admits queries (matches governor hysteresis)", () => {
242
+ const env = makeEnvironment(envDir);
243
+ const governor = new StubGovernor();
244
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
245
+
246
+ governor.backpressured = true;
247
+ expect(() => env.assertCanAdmitQuery()).toThrow(ServiceUnavailableError);
248
+
249
+ governor.backpressured = false;
250
+ expect(() => env.assertCanAdmitQuery()).not.toThrow();
251
+ });
252
+
253
+ it("detaching the governor reverts to legacy admit-everything", () => {
254
+ const env = makeEnvironment(envDir);
255
+ const governor = new StubGovernor();
256
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
257
+ governor.backpressured = true;
258
+
259
+ env.setMemoryGovernor(null);
260
+ // Even with the stub still claiming back-pressure, a detached
261
+ // governor leaves nothing to consult. Mirrors the package-admission
262
+ // detach behavior.
263
+ expect(() => env.assertCanAdmitQuery()).not.toThrow();
264
+ });
265
+ });
266
+
267
+ describe("Environment admission telemetry", () => {
268
+ let envDir: string;
269
+ let harness: MetricsHarness;
270
+
271
+ beforeEach(async () => {
272
+ envDir = fs.mkdtempSync(
273
+ path.join(os.tmpdir(), "publisher-env-admission-telemetry-"),
274
+ );
275
+ harness = await startMetricsHarness();
276
+ resetAdmissionTelemetryForTesting();
277
+ });
278
+
279
+ afterEach(async () => {
280
+ fs.rmSync(envDir, { recursive: true, force: true });
281
+ resetAdmissionTelemetryForTesting();
282
+ await harness.shutdown();
283
+ });
284
+
285
+ it("publisher_query_admission_rejections_total ticks per query rejection, labeled by environment", async () => {
286
+ const env = makeEnvironment(envDir);
287
+ const governor = new StubGovernor();
288
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
289
+ governor.backpressured = true;
290
+
291
+ // Drive three rejections so the counter is unambiguous (a
292
+ // single-tick assertion can pass against a leaked counter
293
+ // from a different test; three is harder to fake).
294
+ for (let i = 0; i < 3; i++) {
295
+ expect(() => env.assertCanAdmitQuery()).toThrow(
296
+ ServiceUnavailableError,
297
+ );
298
+ }
299
+ expect(
300
+ await harness.collectCounter(
301
+ "publisher_query_admission_rejections_total",
302
+ { environment: "test-env" },
303
+ ),
304
+ ).toBe(3);
305
+ });
306
+
307
+ it("counter stays at zero when the governor is happy (no spurious ticks)", async () => {
308
+ const env = makeEnvironment(envDir);
309
+ const governor = new StubGovernor();
310
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
311
+ // Not back-pressured.
312
+ env.assertCanAdmitQuery();
313
+ env.assertCanAdmitQuery();
314
+ // No rejection should have been recorded; verifies that the
315
+ // counter doesn't tick on the happy-path admission either.
316
+ expect(
317
+ await harness.collectCounter(
318
+ "publisher_query_admission_rejections_total",
319
+ ),
320
+ ).toBe(0);
321
+ });
322
+
323
+ it("publisher_package_admission_rejections_total ticks per package-load rejection", async () => {
324
+ // Ensure the package directory exists so the 404 doesn't
325
+ // short-circuit ahead of the back-pressure gate.
326
+ const pkgName = "real-pkg";
327
+ fs.mkdirSync(path.join(envDir, pkgName));
328
+
329
+ const env = makeEnvironment(envDir);
330
+ const governor = new StubGovernor();
331
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
332
+ governor.backpressured = true;
333
+
334
+ await expect(env.addPackage(pkgName)).rejects.toBeInstanceOf(
335
+ ServiceUnavailableError,
336
+ );
337
+ expect(
338
+ await harness.collectCounter(
339
+ "publisher_package_admission_rejections_total",
340
+ { environment: "test-env", reason: "add a new package" },
341
+ ),
342
+ ).toBe(1);
343
+ });
344
+ });
@@ -5,6 +5,7 @@ import * as sinon from "sinon";
5
5
  import { components } from "../api";
6
6
  import { isPublisherConfigFrozen } from "../config";
7
7
  import { TEMP_DIR_PATH } from "../constants";
8
+ import { BadRequestError } from "../errors";
8
9
  import { Environment } from "./environment";
9
10
  import { EnvironmentStore } from "./environment_store";
10
11
 
@@ -1020,3 +1021,105 @@ describe("Project Service Error Recovery", () => {
1020
1021
  );
1021
1022
  });
1022
1023
  });
1024
+
1025
+ const TRAVERSAL_NAMES: ReadonlyArray<readonly [string, string]> = [
1026
+ ["leading traversal", "../etc"],
1027
+ ["embedded traversal", "foo/../../bar"],
1028
+ ["slash in name", "foo/bar"],
1029
+ ["backslash in name", "foo\\bar"],
1030
+ ["leading dot", ".staging"],
1031
+ ["bare dot-dot", ".."],
1032
+ ["bare dot", "."],
1033
+ ["empty", ""],
1034
+ ["NUL byte", "foo\0bar"],
1035
+ ["oversized", "a".repeat(256)],
1036
+ ["absolute", "/etc/passwd"],
1037
+ ] as const;
1038
+
1039
+ describe("EnvironmentStore path-injection guards", () => {
1040
+ let environmentStore: EnvironmentStore;
1041
+
1042
+ beforeEach(async () => {
1043
+ if (existsSync(serverRootPath)) {
1044
+ rmSync(serverRootPath, { recursive: true, force: true });
1045
+ }
1046
+ mkdirSync(serverRootPath);
1047
+ mock(isPublisherConfigFrozen).mockReturnValue(false);
1048
+ mock.module("../config", () => ({
1049
+ isPublisherConfigFrozen: () => false,
1050
+ }));
1051
+ environmentStore = new EnvironmentStore(serverRootPath);
1052
+ await environmentStore.finishedInitialization;
1053
+ });
1054
+
1055
+ afterEach(() => {
1056
+ if (existsSync(serverRootPath)) {
1057
+ rmSync(serverRootPath, { recursive: true, force: true });
1058
+ }
1059
+ mkdirSync(serverRootPath);
1060
+ });
1061
+
1062
+ describe("addEnvironment", () => {
1063
+ it.each(TRAVERSAL_NAMES)(
1064
+ "rejects %s as environment.name (%p)",
1065
+ async (_label, name) => {
1066
+ await expect(
1067
+ environmentStore.addEnvironment({ name } as never, true),
1068
+ ).rejects.toBeInstanceOf(BadRequestError);
1069
+ },
1070
+ );
1071
+
1072
+ it.each(TRAVERSAL_NAMES)(
1073
+ "rejects %s as packages[].name (%p)",
1074
+ async (_label, packageName) => {
1075
+ await expect(
1076
+ environmentStore.addEnvironment(
1077
+ {
1078
+ name: "ok-env",
1079
+ packages: [
1080
+ {
1081
+ name: packageName,
1082
+ location: "https://github.com/example/repo",
1083
+ },
1084
+ ],
1085
+ } as never,
1086
+ true,
1087
+ ),
1088
+ ).rejects.toBeInstanceOf(BadRequestError);
1089
+ },
1090
+ );
1091
+ });
1092
+
1093
+ describe("updateEnvironment", () => {
1094
+ it.each(TRAVERSAL_NAMES)(
1095
+ "rejects %s as environment.name (%p)",
1096
+ async (_label, name) => {
1097
+ await expect(
1098
+ environmentStore.updateEnvironment({ name } as never),
1099
+ ).rejects.toBeInstanceOf(BadRequestError);
1100
+ },
1101
+ );
1102
+ });
1103
+
1104
+ describe("deleteEnvironment", () => {
1105
+ it.each(TRAVERSAL_NAMES)(
1106
+ "rejects %s as environmentName (%p)",
1107
+ async (_label, name) => {
1108
+ await expect(
1109
+ environmentStore.deleteEnvironment(name),
1110
+ ).rejects.toBeInstanceOf(BadRequestError);
1111
+ },
1112
+ );
1113
+ });
1114
+
1115
+ describe("getEnvironment", () => {
1116
+ it.each(TRAVERSAL_NAMES)(
1117
+ "rejects %s as environmentName (%p)",
1118
+ async (_label, name) => {
1119
+ await expect(
1120
+ environmentStore.getEnvironment(name),
1121
+ ).rejects.toBeInstanceOf(BadRequestError);
1122
+ },
1123
+ );
1124
+ });
1125
+ });
@@ -27,6 +27,11 @@ import {
27
27
  } from "../errors";
28
28
  import { getOperationalState, markNotReady, markReady } from "../health";
29
29
  import { formatDuration, logger } from "../logger";
30
+ import {
31
+ assertSafeEnvironmentPath,
32
+ assertSafePackageName,
33
+ safeJoinUnderRoot,
34
+ } from "../path_safety";
30
35
  import { Connection } from "../storage/DatabaseInterface";
31
36
  import { StorageConfig, StorageManager } from "../storage/StorageManager";
32
37
  import { Environment, PackageStatus } from "./environment";
@@ -756,6 +761,7 @@ export class EnvironmentStore {
756
761
  reload: boolean = false,
757
762
  ): Promise<Environment> {
758
763
  await this.finishedInitialization;
764
+ assertSafePackageName(environmentName);
759
765
 
760
766
  // Check if environment is already loaded first
761
767
  const environment = this.environments.get(environmentName);
@@ -816,9 +822,10 @@ export class EnvironmentStore {
816
822
  if (!skipInitialization && this.publisherConfigIsFrozen) {
817
823
  throw new FrozenConfigError();
818
824
  }
825
+ assertSafePackageName(environment.name);
819
826
  const environmentName = environment.name;
820
- if (!environmentName) {
821
- throw new Error("Environment name is required");
827
+ for (const _package of environment.packages || []) {
828
+ assertSafePackageName(_package.name);
822
829
  }
823
830
  // Check if environment already exists and update it instead of creating a new one
824
831
  const existingEnvironment = this.environments.get(environmentName);
@@ -884,6 +891,7 @@ export class EnvironmentStore {
884
891
  }
885
892
 
886
893
  public async unzipEnvironment(absoluteEnvironmentPath: string) {
894
+ assertSafeEnvironmentPath(absoluteEnvironmentPath);
887
895
  const startedAt = Date.now();
888
896
  logger.info(
889
897
  `Detected zip file at "${absoluteEnvironmentPath}". Unzipping...`,
@@ -930,9 +938,10 @@ export class EnvironmentStore {
930
938
  throw new FrozenConfigError();
931
939
  }
932
940
  validateEnvironmentAzureUrls(environment);
941
+ assertSafePackageName(environment.name);
933
942
  const environmentName = environment.name;
934
- if (!environmentName) {
935
- throw new Error("Environment name is required");
943
+ for (const _package of environment.packages || []) {
944
+ assertSafePackageName(_package.name);
936
945
  }
937
946
  const existingEnvironment = this.environments.get(environmentName);
938
947
  if (!existingEnvironment) {
@@ -953,6 +962,7 @@ export class EnvironmentStore {
953
962
  if (this.publisherConfigIsFrozen) {
954
963
  throw new FrozenConfigError();
955
964
  }
965
+ assertSafePackageName(environmentName);
956
966
  const environment = this.environments.get(environmentName);
957
967
  if (!environment) {
958
968
  return;
@@ -1027,15 +1037,17 @@ export class EnvironmentStore {
1027
1037
  }
1028
1038
 
1029
1039
  private async scaffoldEnvironment(environment: ApiEnvironment) {
1040
+ assertSafePackageName(environment.name);
1030
1041
  const environmentName = environment.name;
1031
- if (!environmentName) {
1032
- throw new Error("Environment name is required");
1033
- }
1034
- const absoluteEnvironmentPath = `${this.serverRootPath}/${PUBLISHER_DATA_DIR}/${environmentName}`;
1042
+ const absoluteEnvironmentPath = safeJoinUnderRoot(
1043
+ this.serverRootPath,
1044
+ PUBLISHER_DATA_DIR,
1045
+ environmentName,
1046
+ );
1035
1047
  await fs.promises.mkdir(absoluteEnvironmentPath, { recursive: true });
1036
1048
  if (environment.readme) {
1037
1049
  await fs.promises.writeFile(
1038
- path.join(absoluteEnvironmentPath, "README.md"),
1050
+ safeJoinUnderRoot(absoluteEnvironmentPath, "README.md"),
1039
1051
  environment.readme,
1040
1052
  );
1041
1053
  }
@@ -1071,7 +1083,12 @@ export class EnvironmentStore {
1071
1083
  environmentName: string,
1072
1084
  packages: ApiEnvironment["packages"],
1073
1085
  ) {
1074
- const absoluteTargetPath = `${this.serverRootPath}/${PUBLISHER_DATA_DIR}/${environmentName}`;
1086
+ assertSafePackageName(environmentName);
1087
+ const absoluteTargetPath = safeJoinUnderRoot(
1088
+ this.serverRootPath,
1089
+ PUBLISHER_DATA_DIR,
1090
+ environmentName,
1091
+ );
1075
1092
 
1076
1093
  await fs.promises.mkdir(absoluteTargetPath, { recursive: true });
1077
1094
 
@@ -1126,7 +1143,10 @@ export class EnvironmentStore {
1126
1143
  .update(groupedLocation)
1127
1144
  .digest("hex")
1128
1145
  .substring(0, 16); // Use first 16 chars for shorter paths
1129
- const tempDownloadPath = `${absoluteTargetPath}/.temp_${locationHash}`;
1146
+ const tempDownloadPath = safeJoinUnderRoot(
1147
+ absoluteTargetPath,
1148
+ `.temp_${locationHash}`,
1149
+ );
1130
1150
  await fs.promises.mkdir(tempDownloadPath, { recursive: true });
1131
1151
  logger.info(`Created temporary directory: ${tempDownloadPath}`);
1132
1152
  try {
@@ -1140,7 +1160,11 @@ export class EnvironmentStore {
1140
1160
  // Extract each package from the downloaded content
1141
1161
  for (const _package of packagesForLocation) {
1142
1162
  const packageDir = _package.name;
1143
- const absolutePackagePath = `${absoluteTargetPath}/${packageDir}`;
1163
+ assertSafePackageName(packageDir);
1164
+ const absolutePackagePath = safeJoinUnderRoot(
1165
+ absoluteTargetPath,
1166
+ packageDir,
1167
+ );
1144
1168
  // For GitHub URLs, extract the subdirectory path from the original location
1145
1169
  let sourcePath: string;
1146
1170
  if (this.isGitHubURL(_package.location)) {
@@ -1151,7 +1175,7 @@ export class EnvironmentStore {
1151
1175
  const subPathMatch =
1152
1176
  _package.location.match(/\/tree\/[^/]+\/(.+)$/);
1153
1177
  if (subPathMatch) {
1154
- sourcePath = path.join(
1178
+ sourcePath = safeJoinUnderRoot(
1155
1179
  tempDownloadPath,
1156
1180
  subPathMatch[1],
1157
1181
  );
@@ -1168,7 +1192,10 @@ export class EnvironmentStore {
1168
1192
  if (this.isLocalPath(_package.location)) {
1169
1193
  sourcePath = _package.location;
1170
1194
  } else {
1171
- sourcePath = path.join(tempDownloadPath, groupedLocation);
1195
+ sourcePath = safeJoinUnderRoot(
1196
+ tempDownloadPath,
1197
+ groupedLocation,
1198
+ );
1172
1199
  }
1173
1200
  }
1174
1201
 
@@ -1347,6 +1374,10 @@ export class EnvironmentStore {
1347
1374
  environmentName: string,
1348
1375
  packageName: string,
1349
1376
  ) {
1377
+ // `environmentPath` is the operator-supplied mount source and may
1378
+ // legitimately be a relative path that resolves outside the
1379
+ // server root; only the target is asserted.
1380
+ assertSafeEnvironmentPath(absoluteTargetPath);
1350
1381
  if (environmentPath.endsWith(".zip")) {
1351
1382
  environmentPath = await this.unzipEnvironment(environmentPath);
1352
1383
  }
@@ -1374,6 +1405,7 @@ export class EnvironmentStore {
1374
1405
  absoluteDirPath: string,
1375
1406
  isCompressedFile: boolean,
1376
1407
  ) {
1408
+ assertSafeEnvironmentPath(absoluteDirPath);
1377
1409
  const trimmedPath = gcsPath.slice(5);
1378
1410
  const [bucketName, ...prefixParts] = trimmedPath.split("/");
1379
1411
  const prefix = prefixParts.join("/");
@@ -1396,10 +1428,15 @@ export class EnvironmentStore {
1396
1428
  }
1397
1429
  await Promise.all(
1398
1430
  files.map(async (file) => {
1399
- const relativeFilePath = file.name.replace(prefix, "");
1431
+ // Strip leading `/` left over from prefix removal when the
1432
+ // GCS prefix lacked a trailing slash — otherwise
1433
+ // `safeJoinUnderRoot` treats it as absolute and rejects.
1434
+ const relativeFilePath = file.name
1435
+ .replace(prefix, "")
1436
+ .replace(/^\/+/, "");
1400
1437
  const absoluteFilePath = isCompressedFile
1401
1438
  ? absoluteDirPath
1402
- : path.join(absoluteDirPath, relativeFilePath);
1439
+ : safeJoinUnderRoot(absoluteDirPath, relativeFilePath);
1403
1440
  if (file.name.endsWith("/")) {
1404
1441
  return;
1405
1442
  }
@@ -1424,6 +1461,7 @@ export class EnvironmentStore {
1424
1461
  absoluteDirPath: string,
1425
1462
  isCompressedFile: boolean = false,
1426
1463
  ) {
1464
+ assertSafeEnvironmentPath(absoluteDirPath);
1427
1465
  const trimmedPath = s3Path.slice(5);
1428
1466
  const [bucketName, ...prefixParts] = trimmedPath.split("/");
1429
1467
  const prefix = prefixParts.join("/");
@@ -1477,11 +1515,16 @@ export class EnvironmentStore {
1477
1515
  if (!key) {
1478
1516
  return;
1479
1517
  }
1480
- const relativeFilePath = key.replace(prefix, "");
1518
+ // Strip leading `/` left over from prefix removal when the
1519
+ // S3 prefix lacked a trailing slash — otherwise
1520
+ // `safeJoinUnderRoot` treats it as absolute and rejects.
1521
+ const relativeFilePath = key
1522
+ .replace(prefix, "")
1523
+ .replace(/^\/+/, "");
1481
1524
  if (!relativeFilePath || relativeFilePath.endsWith("/")) {
1482
1525
  return;
1483
1526
  }
1484
- const absoluteFilePath = path.join(
1527
+ const absoluteFilePath = safeJoinUnderRoot(
1485
1528
  absoluteDirPath,
1486
1529
  relativeFilePath,
1487
1530
  );
@@ -1532,6 +1575,7 @@ export class EnvironmentStore {
1532
1575
  }
1533
1576
 
1534
1577
  async downloadGitHubDirectory(githubUrl: string, absoluteDirPath: string) {
1578
+ assertSafeEnvironmentPath(absoluteDirPath);
1535
1579
  // First we'll clone the repo without the additional path
1536
1580
  // E.g. we're removing `/tree/main/imdb` from https://github.com/credibledata/malloy-samples/tree/main/imdb
1537
1581
  const githubInfo = this.parseGitHubUrl(githubUrl);
@@ -1539,7 +1583,11 @@ export class EnvironmentStore {
1539
1583
  throw new Error(`Invalid GitHub URL: ${githubUrl}`);
1540
1584
  }
1541
1585
  const { owner, repoName, packagePath } = githubInfo;
1542
- const cleanPackagePath = packagePath?.replace("/tree/main", "") || "";
1586
+ // `packagePath` is captured with a leading `/`; strip it so the
1587
+ // value is a relative segment usable with `safeJoinUnderRoot`.
1588
+ const cleanPackagePath = (
1589
+ packagePath?.replace("/tree/main", "") || ""
1590
+ ).replace(/^\/+/, "");
1543
1591
 
1544
1592
  // We'll make sure whatever was in absoluteDirPath is removed,
1545
1593
  // so we have a nice a clean directory where we can clone the repo
@@ -1579,7 +1627,10 @@ export class EnvironmentStore {
1579
1627
 
1580
1628
  // Remove all contents of absoluteDirPath (/var/publisher/asd123)
1581
1629
  // except for the cleanPackagePath directory (/var/publisher/asd123/imdb)
1582
- const packageFullPath = path.join(absoluteDirPath, cleanPackagePath);
1630
+ const packageFullPath = safeJoinUnderRoot(
1631
+ absoluteDirPath,
1632
+ cleanPackagePath,
1633
+ );
1583
1634
 
1584
1635
  // Check if the cleanPackagePath (/var/publisher/asd123/imdb) exists
1585
1636
  const packageExists = await fs.promises
@@ -1598,7 +1649,7 @@ export class EnvironmentStore {
1598
1649
  for (const entry of dirContents) {
1599
1650
  // Don't remove the cleanPackagePath directory itself (/var/publisher/asd123/imdb)
1600
1651
  if (entry !== cleanPackagePath.replace(/^\/+/, "").split("/")[0]) {
1601
- await fs.promises.rm(path.join(absoluteDirPath, entry), {
1652
+ await fs.promises.rm(safeJoinUnderRoot(absoluteDirPath, entry), {
1602
1653
  recursive: true,
1603
1654
  force: true,
1604
1655
  });
@@ -1609,8 +1660,8 @@ export class EnvironmentStore {
1609
1660
  const packageContents = await fs.promises.readdir(packageFullPath);
1610
1661
  for (const entry of packageContents) {
1611
1662
  await fs.promises.rename(
1612
- path.join(packageFullPath, entry),
1613
- path.join(absoluteDirPath, entry),
1663
+ safeJoinUnderRoot(packageFullPath, entry),
1664
+ safeJoinUnderRoot(absoluteDirPath, entry),
1614
1665
  );
1615
1666
  }
1616
1667