@malloy-publisher/server 0.0.199 → 0.0.201

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 (64) hide show
  1. package/dist/app/api-doc.yaml +110 -118
  2. package/dist/app/assets/{EnvironmentPage-Dpee_Kn6.js → EnvironmentPage-KoP4wt8H.js} +1 -1
  3. package/dist/app/assets/HomePage-HbPwKL84.js +1 -0
  4. package/dist/app/assets/MainPage-DfK4zDYO.js +2 -0
  5. package/dist/app/assets/{ModelPage-AwAugZ37.js → ModelPage-CUgSwGXg.js} +1 -1
  6. package/dist/app/assets/{PackagePage-XQ-EWGTC.js → PackagePage-CUDQNL5k.js} +1 -1
  7. package/dist/app/assets/{RouteError-3Mv8JQw7.js → RouteError-sgmtBdg8.js} +1 -1
  8. package/dist/app/assets/{WorkbookPage-DHYYpcYc.js → WorkbookPage-tnWmLcrW.js} +1 -1
  9. package/dist/app/assets/{core-DfcpQGVP.es-DQggNOdX.js → core-B3IQNPBD.es-foBNuT8L.js} +10 -10
  10. package/dist/app/assets/{index-D1pdwrUW.js → index-B5We8x8r.js} +1 -1
  11. package/dist/app/assets/{index-BUp81Qdm.js → index-KIvi9k3F.js} +1 -1
  12. package/dist/app/assets/index-PNYovl3E.js +452 -0
  13. package/dist/app/assets/{index.umd-CQH4LZU8.js → index.umd-BXcsl2XW.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 +1556 -1018
  17. package/package.json +1 -1
  18. package/publisher.config.json +4 -0
  19. package/src/config.spec.ts +246 -0
  20. package/src/config.ts +121 -1
  21. package/src/constants.ts +84 -1
  22. package/src/controller/connection.controller.spec.ts +803 -0
  23. package/src/controller/connection.controller.ts +207 -20
  24. package/src/controller/model.controller.ts +16 -5
  25. package/src/controller/query.controller.ts +20 -7
  26. package/src/controller/watch-mode.controller.ts +11 -2
  27. package/src/errors.spec.ts +44 -0
  28. package/src/errors.ts +34 -0
  29. package/src/filter_deprecation.spec.ts +64 -0
  30. package/src/filter_deprecation.ts +42 -0
  31. package/src/heap_check.spec.ts +144 -0
  32. package/src/heap_check.ts +144 -0
  33. package/src/mcp/handler_utils.ts +14 -0
  34. package/src/mcp/tools/execute_query_tool.ts +44 -14
  35. package/src/oom_guards.integration.spec.ts +261 -0
  36. package/src/path_safety.ts +9 -3
  37. package/src/query_cap_metrics.spec.ts +89 -0
  38. package/src/query_cap_metrics.ts +115 -0
  39. package/src/query_concurrency.spec.ts +247 -0
  40. package/src/query_concurrency.ts +236 -0
  41. package/src/query_timeout.spec.ts +224 -0
  42. package/src/query_timeout.ts +178 -0
  43. package/src/server-old.ts +20 -0
  44. package/src/server.ts +57 -72
  45. package/src/service/connection.spec.ts +244 -0
  46. package/src/service/connection.ts +14 -4
  47. package/src/service/environment.ts +124 -4
  48. package/src/service/environment_admission.spec.ts +165 -1
  49. package/src/service/environment_store.spec.ts +103 -0
  50. package/src/service/environment_store.ts +74 -23
  51. package/src/service/filter_integration.spec.ts +69 -0
  52. package/src/service/model.spec.ts +193 -3
  53. package/src/service/model.ts +95 -14
  54. package/src/service/model_limits.spec.ts +181 -0
  55. package/src/service/model_limits.ts +110 -0
  56. package/src/service/package.spec.ts +2 -6
  57. package/src/service/package.ts +6 -1
  58. package/src/service/path_injection.spec.ts +39 -0
  59. package/src/stream_helpers.spec.ts +280 -0
  60. package/src/stream_helpers.ts +162 -0
  61. package/src/test_helpers/metrics_harness.ts +126 -0
  62. package/dist/app/assets/HomePage-DLRWTNoL.js +0 -1
  63. package/dist/app/assets/MainPage-DsVt5QGM.js +0 -2
  64. package/dist/app/assets/index-Dv5bF4Ii.js +0 -451
@@ -35,15 +35,17 @@ import type {
35
35
  SerializedNotebookCell,
36
36
  } from "../package_load/protocol";
37
37
  import {
38
- MODEL_FILE_SUFFIX,
39
- NOTEBOOK_FILE_SUFFIX,
40
- ROW_LIMIT,
41
- } from "../constants";
38
+ getDefaultQueryRowLimit,
39
+ getMaxQueryRows,
40
+ getMaxResponseBytes,
41
+ } from "../config";
42
+ import { MODEL_FILE_SUFFIX, NOTEBOOK_FILE_SUFFIX } from "../constants";
42
43
  import { HackyDataStylesAccumulator } from "../data_styles";
43
44
  import {
44
45
  BadRequestError,
45
46
  ModelCompilationError,
46
47
  ModelNotFoundError,
48
+ PayloadTooLargeError,
47
49
  } from "../errors";
48
50
  import { logger } from "../logger";
49
51
  import { BuildManifest } from "../storage/DatabaseInterface";
@@ -57,6 +59,10 @@ import {
57
59
  type FilterParams,
58
60
  } from "./filter";
59
61
  import { malloyGivenToApi, type MalloyGiven } from "./given";
62
+ import {
63
+ assertWithinModelResponseLimits,
64
+ resolveModelQueryRowLimit,
65
+ } from "./model_limits";
60
66
 
61
67
  type ApiCompiledModel = components["schemas"]["CompiledModel"];
62
68
  type ApiNotebookCell = components["schemas"]["NotebookCell"];
@@ -160,6 +166,21 @@ export class Model {
160
166
  this.modelInfo =
161
167
  modelInfo ??
162
168
  (this.modelDef ? modelDefToModelInfo(this.modelDef) : undefined);
169
+
170
+ // One-time deprecation notice per Model instance. Surfaces only when
171
+ // the model declares `#(filter)` annotations so operators migrating
172
+ // toward `given:` see a clear pointer in the server log without
173
+ // spamming for models that have already moved over.
174
+ if (this.filterMap.size > 0) {
175
+ logger.warn(
176
+ `Model "${packageName}/${modelPath}" uses deprecated #(filter) annotations. Migrate to given: — see https://github.com/malloydata/publisher/blob/main/docs/givens.md`,
177
+ {
178
+ packageName,
179
+ modelPath,
180
+ filterSourceCount: this.filterMap.size,
181
+ },
182
+ );
183
+ }
163
184
  }
164
185
 
165
186
  /**
@@ -504,6 +525,14 @@ export class Model {
504
525
  filterParams?: FilterParams,
505
526
  bypassFilters?: boolean,
506
527
  givens?: Record<string, GivenValue>,
528
+ // Optional caller-supplied abort signal. Plumbed straight into
529
+ // `runnable.run` so a publisher-issued query timeout (see
530
+ // `runWithQueryTimeout`) actually cancels the work in flight
531
+ // instead of just unblocking the awaiter. Pass `undefined` to
532
+ // keep the legacy "no timeout" behavior — useful for
533
+ // background callers (materialization, tests) that own their
534
+ // own deadline.
535
+ abortSignal?: AbortSignal,
507
536
  ): Promise<{
508
537
  result: Malloy.Result;
509
538
  compactResult: QueryData;
@@ -597,15 +626,18 @@ export class Model {
597
626
  throw new BadRequestError(`Invalid query: ${errorMessage}`);
598
627
  }
599
628
 
600
- const rowLimit =
601
- (await runnable.getPreparedResult({ givens })).resultExplore.limit ||
602
- ROW_LIMIT;
629
+ const maxRows = getMaxQueryRows();
630
+ const maxBytes = getMaxResponseBytes();
631
+ const rowLimit = resolveModelQueryRowLimit(
632
+ (await runnable.getPreparedResult({ givens })).resultExplore.limit,
633
+ { defaultLimit: getDefaultQueryRowLimit(), maxRows },
634
+ );
603
635
  const endTime = performance.now();
604
636
  const executionTime = endTime - startTime;
605
637
 
606
638
  let queryResults;
607
639
  try {
608
- queryResults = await runnable.run({ rowLimit, givens });
640
+ queryResults = await runnable.run({ rowLimit, givens, abortSignal });
609
641
  } catch (error) {
610
642
  // Record error metrics
611
643
  const errorEndTime = performance.now();
@@ -638,6 +670,24 @@ export class Model {
638
670
  throw new BadRequestError(`Query execution failed: ${errorMessage}`);
639
671
  }
640
672
 
673
+ const wrappedResult = API.util.wrapResult(queryResults);
674
+ // Best-effort byte check: we've already buffered `queryResults` and
675
+ // built `wrappedResult` by the time we get here, so this surfaces
676
+ // oversize responses with a clean HTTP 413 instead of letting the
677
+ // controller transmit a half-megabyte payload — it is not OOM
678
+ // prevention. True prevention requires streaming `Result`
679
+ // construction, which is out of scope for this step. The row cap
680
+ // above is the primary OOM defense.
681
+ const serializedBytes =
682
+ maxBytes > 0
683
+ ? Buffer.byteLength(JSON.stringify(wrappedResult), "utf8")
684
+ : 0;
685
+ assertWithinModelResponseLimits(
686
+ queryResults.totalRows,
687
+ serializedBytes,
688
+ { maxRows, maxBytes },
689
+ "model_query",
690
+ );
641
691
  this.queryExecutionHistogram.record(executionTime, {
642
692
  "malloy.model.path": this.modelPath,
643
693
  "malloy.model.query.name": queryName,
@@ -649,7 +699,7 @@ export class Model {
649
699
  "malloy.model.query.status": "success",
650
700
  });
651
701
  return {
652
- result: API.util.wrapResult(queryResults),
702
+ result: wrappedResult,
653
703
  compactResult: queryResults.data.value,
654
704
  modelInfo: this.modelInfo,
655
705
  dataStyles: this.dataStyles,
@@ -682,7 +732,6 @@ export class Model {
682
732
  const notebookCells: ApiNotebookCell[] = (
683
733
  this.runnableNotebookCells as RunnableNotebookCell[]
684
734
  ).map((cell) => {
685
- logger.debug("cell.queryInfo", cell.queryInfo);
686
735
  return {
687
736
  type: cell.type,
688
737
  text: cell.text,
@@ -732,6 +781,9 @@ export class Model {
732
781
  filterParams?: FilterParams,
733
782
  bypassFilters?: boolean,
734
783
  givens?: Record<string, GivenValue>,
784
+ // See `getQueryResults`: forwarded into `runnable.run` so the
785
+ // publisher's wall-clock timeout actually cancels the query.
786
+ abortSignal?: AbortSignal,
735
787
  ): Promise<{
736
788
  type: "code" | "markdown";
737
789
  text: string;
@@ -792,16 +844,40 @@ export class Model {
792
844
  }
793
845
  }
794
846
 
795
- const rowLimit =
847
+ const cellMaxRows = getMaxQueryRows();
848
+ const cellMaxBytes = getMaxResponseBytes();
849
+ const rowLimit = resolveModelQueryRowLimit(
796
850
  (await runnableToExecute.getPreparedResult({ givens }))
797
- .resultExplore.limit || ROW_LIMIT;
798
- const result = await runnableToExecute.run({ rowLimit, givens });
851
+ .resultExplore.limit,
852
+ {
853
+ defaultLimit: getDefaultQueryRowLimit(),
854
+ maxRows: cellMaxRows,
855
+ },
856
+ );
857
+ const result = await runnableToExecute.run({
858
+ rowLimit,
859
+ givens,
860
+ abortSignal,
861
+ });
799
862
  const query = (await runnableToExecute.getPreparedQuery())._query;
800
863
  queryName = (query as NamedQueryDef).as || query.name;
801
864
  queryResult =
802
865
  result?._queryResult &&
803
866
  this.modelInfo &&
804
867
  JSON.stringify(API.util.wrapResult(result));
868
+ // Same caveat as `getQueryResults`: by the time we measure
869
+ // bytes the response has already been buffered and stringified,
870
+ // so this is loud-failure detection (clean 413 instead of
871
+ // partial transmission), not OOM prevention. The row cap above
872
+ // is the primary defense.
873
+ if (result?._queryResult && queryResult) {
874
+ assertWithinModelResponseLimits(
875
+ result.totalRows,
876
+ Buffer.byteLength(queryResult, "utf8"),
877
+ { maxRows: cellMaxRows, maxBytes: cellMaxBytes },
878
+ "notebook_cell",
879
+ );
880
+ }
805
881
  } catch (error) {
806
882
  if (error instanceof FilterValidationError) {
807
883
  throw new BadRequestError(error.message);
@@ -809,6 +885,12 @@ export class Model {
809
885
  if (error instanceof MalloyError) {
810
886
  throw error;
811
887
  }
888
+ // Surface PayloadTooLargeError as-is so the error middleware
889
+ // maps it to HTTP 413; without this it would get swallowed
890
+ // into a generic 400 BadRequestError below.
891
+ if (error instanceof PayloadTooLargeError) {
892
+ throw error;
893
+ }
812
894
  const errorMessage =
813
895
  error instanceof Error ? error.message : String(error);
814
896
  if (errorMessage.trim() === "Model has no queries.") {
@@ -819,7 +901,6 @@ export class Model {
819
901
  } else {
820
902
  logger.error("Error message: ", errorMessage);
821
903
  }
822
- logger.debug("Cell content: ", cellIndex, cell.type, cell.text);
823
904
  throw new BadRequestError(`Cell execution failed: ${errorMessage}`);
824
905
  }
825
906
  }
@@ -0,0 +1,181 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { PayloadTooLargeError } from "../errors";
4
+ import {
5
+ assertWithinModelResponseLimits,
6
+ resolveModelQueryRowLimit,
7
+ } from "./model_limits";
8
+
9
+ describe("resolveModelQueryRowLimit", () => {
10
+ it("uses the user's LIMIT when set and below the maxRows ceiling", () => {
11
+ expect(
12
+ resolveModelQueryRowLimit(500, {
13
+ defaultLimit: 1000,
14
+ maxRows: 100_000,
15
+ }),
16
+ ).toBe(500);
17
+ });
18
+
19
+ it("falls back to defaultLimit when the user's LIMIT is undefined", () => {
20
+ expect(
21
+ resolveModelQueryRowLimit(undefined, {
22
+ defaultLimit: 1000,
23
+ maxRows: 100_000,
24
+ }),
25
+ ).toBe(1000);
26
+ });
27
+
28
+ it("falls back to defaultLimit when the user's LIMIT is 0 (Malloy returns 0 for 'no limit')", () => {
29
+ // Malloy's PreparedResult returns 0 from `resultExplore.limit` when the
30
+ // query has no LIMIT clause; treat that as "no user limit".
31
+ expect(
32
+ resolveModelQueryRowLimit(0, {
33
+ defaultLimit: 1000,
34
+ maxRows: 100_000,
35
+ }),
36
+ ).toBe(1000);
37
+ });
38
+
39
+ it("clamps a too-high user LIMIT to the maxRows + 1 sentinel", () => {
40
+ expect(
41
+ resolveModelQueryRowLimit(1_000_000, {
42
+ defaultLimit: 1000,
43
+ maxRows: 100_000,
44
+ }),
45
+ ).toBe(100_001);
46
+ });
47
+
48
+ it("clamps a too-high defaultLimit to the maxRows + 1 sentinel", () => {
49
+ // Operator misconfigured DEFAULT > MAX; the hard cap still wins.
50
+ expect(
51
+ resolveModelQueryRowLimit(undefined, {
52
+ defaultLimit: 1_000_000,
53
+ maxRows: 50,
54
+ }),
55
+ ).toBe(51);
56
+ });
57
+
58
+ it("returns the requested limit unchanged when maxRows is 0 (cap disabled)", () => {
59
+ expect(
60
+ resolveModelQueryRowLimit(1_000_000, {
61
+ defaultLimit: 1000,
62
+ maxRows: 0,
63
+ }),
64
+ ).toBe(1_000_000);
65
+ });
66
+
67
+ it("returns the default unchanged when maxRows is 0 and no user limit", () => {
68
+ expect(
69
+ resolveModelQueryRowLimit(undefined, {
70
+ defaultLimit: 1000,
71
+ maxRows: 0,
72
+ }),
73
+ ).toBe(1000);
74
+ });
75
+
76
+ it("rejects negative user limits by treating them as 'no limit'", () => {
77
+ // Defensive — shouldn't happen in practice, but a -1 from a malformed
78
+ // PreparedResult shouldn't propagate as a negative rowLimit to the driver.
79
+ expect(
80
+ resolveModelQueryRowLimit(-1, {
81
+ defaultLimit: 1000,
82
+ maxRows: 100_000,
83
+ }),
84
+ ).toBe(1000);
85
+ });
86
+ });
87
+
88
+ describe("assertWithinModelResponseLimits", () => {
89
+ it("does not throw when both counts are below their caps", () => {
90
+ expect(() =>
91
+ assertWithinModelResponseLimits(
92
+ 500,
93
+ 1_000,
94
+ { maxRows: 1000, maxBytes: 10_000 },
95
+ "model_query",
96
+ ),
97
+ ).not.toThrow();
98
+ });
99
+
100
+ it("does not throw when row count equals the cap exactly (sentinel hasn't fired)", () => {
101
+ expect(() =>
102
+ assertWithinModelResponseLimits(
103
+ 1000,
104
+ 1_000,
105
+ { maxRows: 1000, maxBytes: 10_000 },
106
+ "model_query",
107
+ ),
108
+ ).not.toThrow();
109
+ });
110
+
111
+ it("throws PayloadTooLargeError with the row-cap message on row overflow", () => {
112
+ expect(() =>
113
+ assertWithinModelResponseLimits(
114
+ 1001,
115
+ 1_000,
116
+ { maxRows: 1000, maxBytes: 10_000 },
117
+ "model_query",
118
+ ),
119
+ ).toThrow(PayloadTooLargeError);
120
+ expect(() =>
121
+ assertWithinModelResponseLimits(
122
+ 1001,
123
+ 1_000,
124
+ { maxRows: 1000, maxBytes: 10_000 },
125
+ "model_query",
126
+ ),
127
+ ).toThrow("more than 1000 rows");
128
+ });
129
+
130
+ it("throws PayloadTooLargeError with the byte-cap message on byte overflow", () => {
131
+ expect(() =>
132
+ assertWithinModelResponseLimits(
133
+ 10,
134
+ 50_000,
135
+ { maxRows: 1000, maxBytes: 10_000 },
136
+ "model_query",
137
+ ),
138
+ ).toThrow(PayloadTooLargeError);
139
+ expect(() =>
140
+ assertWithinModelResponseLimits(
141
+ 10,
142
+ 50_000,
143
+ { maxRows: 1000, maxBytes: 10_000 },
144
+ "model_query",
145
+ ),
146
+ ).toThrow("exceeded 10000 bytes");
147
+ });
148
+
149
+ it("prefers the row-cap message when both caps would have fired (row check runs first)", () => {
150
+ expect(() =>
151
+ assertWithinModelResponseLimits(
152
+ 2000,
153
+ 50_000,
154
+ { maxRows: 1000, maxBytes: 10_000 },
155
+ "model_query",
156
+ ),
157
+ ).toThrow("more than 1000 rows");
158
+ });
159
+
160
+ it("disables row cap when maxRows is 0", () => {
161
+ expect(() =>
162
+ assertWithinModelResponseLimits(
163
+ 1_000_000,
164
+ 1_000,
165
+ { maxRows: 0, maxBytes: 10_000 },
166
+ "model_query",
167
+ ),
168
+ ).not.toThrow();
169
+ });
170
+
171
+ it("disables byte cap when maxBytes is 0", () => {
172
+ expect(() =>
173
+ assertWithinModelResponseLimits(
174
+ 10,
175
+ 1_000_000_000,
176
+ { maxRows: 1000, maxBytes: 0 },
177
+ "model_query",
178
+ ),
179
+ ).not.toThrow();
180
+ });
181
+ });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Memory guards for the Malloy model-query path (the `runnable.run`
3
+ * flow used by `getQueryResults` and notebook cell execution).
4
+ *
5
+ * Two layered defenses:
6
+ *
7
+ * 1. {@link resolveModelQueryRowLimit} — compute the effective
8
+ * `rowLimit` to push down to `runnable.run`. The user's Malloy
9
+ * `LIMIT` clause wins when present; otherwise the operator-
10
+ * tunable default ({@link getDefaultQueryRowLimit}) fills in.
11
+ * Either way the result is clamped to `maxRows + 1` so the
12
+ * database itself stops producing rows when a user-supplied
13
+ * `LIMIT 1_000_000` would otherwise blow up the process.
14
+ *
15
+ * 2. {@link assertWithinModelResponseLimits} — post-run overflow
16
+ * detection. If the connector returned `maxRows + 1` rows
17
+ * (the sentinel) or the JSON-serialized response exceeds the
18
+ * byte cap, throw `PayloadTooLargeError` so the caller sees a
19
+ * clean HTTP 413.
20
+ *
21
+ * Caveat on the byte cap: this path runs `runnable.run` (buffered),
22
+ * not `runStream`, so by the time we measure bytes the result has
23
+ * already been materialized in memory. The byte cap here is loud-
24
+ * failure detection — it surfaces oversize responses with a 413
25
+ * instead of letting the client receive a half-transmitted payload
26
+ * — not OOM prevention. True prevention requires streaming +
27
+ * `Result` reconstruction from `DataRecord`s, which is out of scope
28
+ * for this step (the model-query streaming path entangles with
29
+ * Malloy's `Result` schema metadata in non-trivial ways).
30
+ *
31
+ * Both helpers are pure so they can be unit-tested without spinning
32
+ * up a model runtime; the caller injects the env-derived limits.
33
+ */
34
+
35
+ import { PayloadTooLargeError } from "../errors";
36
+ import {
37
+ recordQueryCapExceeded,
38
+ type QueryCapSource,
39
+ } from "../query_cap_metrics";
40
+
41
+ export interface ResolveRowLimitConfig {
42
+ /**
43
+ * Result of {@link getDefaultQueryRowLimit}. Applied when the
44
+ * user's Malloy query doesn't carry a `LIMIT` clause.
45
+ */
46
+ defaultLimit: number;
47
+ /**
48
+ * Result of {@link getMaxQueryRows}. The effective row limit is
49
+ * clamped to `maxRows + 1` so a sentinel-count overflow check can
50
+ * distinguish "ran right up to the cap" from "would have
51
+ * overflowed". A value of `0` disables the cap.
52
+ */
53
+ maxRows: number;
54
+ }
55
+
56
+ /**
57
+ * Compute the `rowLimit` to pass to `runnable.run`. The +1 sentinel
58
+ * mirrors the Step 1 / Step 2 patterns on the connection-query path
59
+ * so behavior is uniform across all query surfaces.
60
+ */
61
+ export function resolveModelQueryRowLimit(
62
+ userLimit: number | undefined,
63
+ { defaultLimit, maxRows }: ResolveRowLimitConfig,
64
+ ): number {
65
+ const requested = userLimit && userLimit > 0 ? userLimit : defaultLimit;
66
+ if (maxRows <= 0) return requested;
67
+ return Math.min(requested, maxRows + 1);
68
+ }
69
+
70
+ export interface ModelResponseLimitsConfig {
71
+ /** Result of {@link getMaxQueryRows}. `0` disables the row cap. */
72
+ maxRows: number;
73
+ /** Result of {@link getMaxResponseBytes}. `0` disables the byte cap. */
74
+ maxBytes: number;
75
+ }
76
+
77
+ /**
78
+ * Throw {@link PayloadTooLargeError} (HTTP 413) when a model-query
79
+ * response exceeds either configured cap. `rowCount` should be the
80
+ * raw row count Malloy actually fetched (typically
81
+ * `result._queryResult.data.rawData.length`); `serializedBytes`
82
+ * should be the byte length of the JSON-stringified response that
83
+ * would otherwise be returned to the client.
84
+ *
85
+ * Row check uses the `> maxRows` sentinel (not `>= maxRows`), since
86
+ * {@link resolveModelQueryRowLimit} asked the connector for
87
+ * `maxRows + 1` and we want to fail only when that sentinel fires.
88
+ */
89
+ export function assertWithinModelResponseLimits(
90
+ rowCount: number,
91
+ serializedBytes: number,
92
+ { maxRows, maxBytes }: ModelResponseLimitsConfig,
93
+ source: QueryCapSource,
94
+ ): void {
95
+ if (maxRows > 0 && rowCount > maxRows) {
96
+ // Tick the counter *before* throwing so it reflects the
97
+ // event even if a downstream `catch` swallows the error
98
+ // (notebook handlers and MCP tools both do this in places).
99
+ recordQueryCapExceeded("rows", source);
100
+ throw new PayloadTooLargeError(
101
+ `Query returned more than ${maxRows} rows. Refine the query (add a LIMIT or more selective WHERE) or raise PUBLISHER_MAX_QUERY_ROWS.`,
102
+ );
103
+ }
104
+ if (maxBytes > 0 && serializedBytes > maxBytes) {
105
+ recordQueryCapExceeded("bytes", source);
106
+ throw new PayloadTooLargeError(
107
+ `Query response exceeded ${maxBytes} bytes (was ${serializedBytes}). Project fewer columns, add a LIMIT, or raise PUBLISHER_MAX_RESPONSE_BYTES.`,
108
+ );
109
+ }
110
+ }
@@ -13,7 +13,7 @@ import { Package } from "./package";
13
13
  type PartialModel = Pick<Model, "getPath">;
14
14
 
15
15
  describe("service/package", () => {
16
- const testPackageDirectory = "testPackage";
16
+ const testPackageDirectory = resolve("testPackage");
17
17
 
18
18
  beforeEach(async () => {
19
19
  await fs.mkdir(testPackageDirectory, { recursive: true });
@@ -100,11 +100,7 @@ describe("service/package", () => {
100
100
  testPackageDirectory,
101
101
  new Map(),
102
102
  ),
103
- ).rejects.toThrowError(
104
- new PackageNotFoundError(
105
- "Package manifest for testPackage does not exist.",
106
- ),
107
- );
103
+ ).rejects.toBeInstanceOf(PackageNotFoundError);
108
104
  });
109
105
  it(
110
106
  "should return a Package object if the package exists",
@@ -28,6 +28,7 @@ import {
28
28
  ServiceUnavailableError,
29
29
  } from "../errors";
30
30
  import { formatDuration, logger } from "../logger";
31
+ import { assertSafeEnvironmentPath, safeJoinUnderRoot } from "../path_safety";
31
32
  import { BuildManifest } from "../storage/DatabaseInterface";
32
33
  import { ignoreDotfiles } from "../utils";
33
34
  import { Model } from "./model";
@@ -90,6 +91,7 @@ export class Package {
90
91
  packagePath: string,
91
92
  environmentMalloyConfig: PackageConnectionInput,
92
93
  ): Promise<Package> {
94
+ assertSafeEnvironmentPath(packagePath);
93
95
  const startTime = performance.now();
94
96
  await Package.validatePackageManifestExistsOrThrowError(packagePath);
95
97
  const manifestValidationTime = performance.now();
@@ -515,7 +517,10 @@ export class Package {
515
517
  private static async validatePackageManifestExistsOrThrowError(
516
518
  packagePath: string,
517
519
  ) {
518
- const packageConfigPath = path.join(packagePath, PACKAGE_MANIFEST_NAME);
520
+ const packageConfigPath = safeJoinUnderRoot(
521
+ packagePath,
522
+ PACKAGE_MANIFEST_NAME,
523
+ );
519
524
  try {
520
525
  await fs.stat(packageConfigPath);
521
526
  } catch {
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { BadRequestError } from "../errors";
3
+ import { deleteDuckLakeConnectionFile } from "./connection";
4
+
5
+ const TRAVERSAL_NAMES: ReadonlyArray<readonly [string, string]> = [
6
+ ["leading traversal", "../etc"],
7
+ ["embedded traversal", "foo/../../bar"],
8
+ ["slash in name", "foo/bar"],
9
+ ["backslash in name", "foo\\bar"],
10
+ ["leading dot", ".staging"],
11
+ ["bare dot-dot", ".."],
12
+ ["bare dot", "."],
13
+ ["empty", ""],
14
+ ["NUL byte", "foo\0bar"],
15
+ ["oversized", "a".repeat(256)],
16
+ ["absolute", "/etc/passwd"],
17
+ ] as const;
18
+
19
+ describe("deleteDuckLakeConnectionFile path-injection guards", () => {
20
+ it.each(TRAVERSAL_NAMES)(
21
+ "rejects %s as connectionName (%p)",
22
+ async (_label, connectionName) => {
23
+ await expect(
24
+ deleteDuckLakeConnectionFile(connectionName, "/tmp/env"),
25
+ ).rejects.toBeInstanceOf(BadRequestError);
26
+ },
27
+ );
28
+
29
+ it.each([
30
+ ["relative", "relative/path"],
31
+ ["traversal", "/var/lib/../../etc"],
32
+ ["NUL byte", "/var/lib/env\0"],
33
+ ["bare dot-dot", ".."],
34
+ ])("rejects %s as environmentPath (%p)", async (_label, environmentPath) => {
35
+ await expect(
36
+ deleteDuckLakeConnectionFile("conn", environmentPath),
37
+ ).rejects.toBeInstanceOf(BadRequestError);
38
+ });
39
+ });