@contractkit/plugin-typescript 0.27.0 → 0.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build$colon$ci.log +3 -3
- package/.turbo/turbo-test$colon$ci.log +19 -18
- package/CHANGELOG.md +14 -0
- package/dist/codegen-contract.d.ts.map +1 -1
- package/dist/codegen-sdk.d.ts.map +1 -1
- package/dist/index.js +239 -215
- package/dist/index.js.map +1 -1
- package/dist/path-utils.d.ts.map +1 -1
- package/dist/ts-render.d.ts +6 -0
- package/dist/ts-render.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/codegen-contract.ts +7 -4
- package/src/codegen-operation.ts +9 -9
- package/src/codegen-plain-types.ts +17 -14
- package/src/codegen-sdk.ts +5 -4
- package/src/path-utils.ts +37 -20
- package/src/ts-render.ts +21 -6
- package/tests/codegen-contract.test.ts +4 -0
- package/tests/codegen-operation.test.ts +5 -5
- package/tests/codegen-sdk.test.ts +8 -0
- package/tests/escaping-security.test.ts +143 -0
- package/coverage/base.css +0 -224
- package/coverage/block-navigation.js +0 -87
- package/coverage/clover.xml +0 -2213
- package/coverage/coverage-final.json +0 -9
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +0 -131
- package/coverage/prettify.css +0 -1
- package/coverage/prettify.js +0 -2
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +0 -210
- package/coverage/src/codegen-contract.ts.html +0 -3661
- package/coverage/src/codegen-operation.ts.html +0 -2584
- package/coverage/src/codegen-plain-types.ts.html +0 -997
- package/coverage/src/codegen-sdk.ts.html +0 -3901
- package/coverage/src/index.html +0 -206
- package/coverage/src/index.ts.html +0 -2761
- package/coverage/src/path-utils.ts.html +0 -745
- package/coverage/src/ts-render.ts.html +0 -592
- package/coverage/tests/helpers.ts.html +0 -826
- package/coverage/tests/index.html +0 -116
package/dist/path-utils.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"path-utils.d.ts","sourceRoot":"","sources":["../src/path-utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGtE,eAAO,MAAM,eAAe,QAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"path-utils.d.ts","sourceRoot":"","sources":["../src/path-utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGtE,eAAO,MAAM,eAAe,QAAY,CAAC;AAmBzC,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAEtF;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAGnD;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAclE;AAID,wBAAgB,gBAAgB,CAC5B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAiBR;AAED,wBAAgB,sBAAsB,CAClC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAER;AAID,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAkBf;AAED;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAoBnH;AAED,wBAAgB,qBAAqB,CACjC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAef;AAED,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,CAiBnG;AAED,wBAAgB,6BAA6B,CACzC,MAAM,EAAE,UAAU,EAAE,EACpB,YAAY,EAAE,gBAAgB,EAAE,EAChC,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,EAC5B,gBAAgB,GAAE,GAAG,CAAC,MAAM,CAAa,GAC1C,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CA0CpB"}
|
package/dist/ts-render.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { ContractTypeNode } from '@contractkit/core';
|
|
2
2
|
export declare const JSON_VALUE_TYPE_DECL = "export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };";
|
|
3
3
|
export declare function quoteKey(name: string): string;
|
|
4
|
+
/** Escape text for safe inclusion inside a JSDoc block comment: neutralize the
|
|
5
|
+
* block-comment terminator sequence and split embedded newlines into separate
|
|
6
|
+
* ` * ` continuation lines. Returns the content lines (WITHOUT a leading prefix). */
|
|
7
|
+
export declare function escapeJsDocLines(text: string): string[];
|
|
8
|
+
/** Escape a string for inclusion inside a single-quoted TypeScript string literal. */
|
|
9
|
+
export declare function escapeSingleQuoted(s: string): string;
|
|
4
10
|
/** Convert an HTTP header name (e.g. `preference-applied`, `X-Request-ID`, `ETag`) to camelCase for use as a JS property. */
|
|
5
11
|
export declare function headerNameToProperty(name: string): string;
|
|
6
12
|
export declare function renderTsType(type: ContractTypeNode): string;
|
package/dist/ts-render.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,
|
|
1
|
+
{"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,GAAG,MAAM,CAoC3D;AA+CD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CA2B/F;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CA2BjG"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contractkit/plugin-typescript",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.1",
|
|
4
4
|
"description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Marooned Software",
|
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
".": "./dist/index.js"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@contractkit/core": "0.
|
|
29
|
+
"@contractkit/core": "0.23.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@repo/config-
|
|
33
|
-
"@repo/config-
|
|
32
|
+
"@repo/config-eslint": "0.3.1",
|
|
33
|
+
"@repo/config-typescript": "0.1.0"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
36
|
"build": "tsup src/index.ts --format esm --sourcemap --dts && tsc --emitDeclarationOnly --declaration",
|
package/src/codegen-contract.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
computeModelsWithOutput as ckComputeModelsWithOutput,
|
|
22
22
|
collectExternalOutputRefs as ckCollectExternalOutputRefs,
|
|
23
23
|
} from '@contractkit/core';
|
|
24
|
+
import { escapeJsDocLines } from './ts-render.js';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Maps a ContractKit object mode to its Zod constructor name.
|
|
@@ -108,7 +109,7 @@ function generateComments(model: ModelNode, outPath?: string): string[] {
|
|
|
108
109
|
lines.push(` * @deprecated`);
|
|
109
110
|
}
|
|
110
111
|
if (model.description) {
|
|
111
|
-
lines.push(` * ${
|
|
112
|
+
for (const l of escapeJsDocLines(model.description)) lines.push(` * ${l}`);
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
const relPath = outPath ? relative(dirname(outPath), model.loc.file) : model.loc.file;
|
|
@@ -664,8 +665,10 @@ function renderScalar(s: ScalarTypeNode): string {
|
|
|
664
665
|
return '_ZodBinary';
|
|
665
666
|
case 'json':
|
|
666
667
|
return '_ZodJson';
|
|
667
|
-
default:
|
|
668
|
-
|
|
668
|
+
default: {
|
|
669
|
+
const _exhaustive: never = s.name;
|
|
670
|
+
throw new Error(`plugin-typescript: unmapped scalar '${String(_exhaustive)}' — add a case`);
|
|
671
|
+
}
|
|
669
672
|
}
|
|
670
673
|
}
|
|
671
674
|
|
|
@@ -685,7 +688,7 @@ function renderRecord(r: RecordTypeNode): string {
|
|
|
685
688
|
}
|
|
686
689
|
|
|
687
690
|
function renderEnum(e: EnumTypeNode): string {
|
|
688
|
-
const vals = e.values.map(v => `"${v}"`).join(', ');
|
|
691
|
+
const vals = e.values.map(v => `"${escapeString(v)}"`).join(', ');
|
|
689
692
|
return `z.enum([${vals}])`;
|
|
690
693
|
}
|
|
691
694
|
|
package/src/codegen-operation.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
typeNeedsScalar,
|
|
10
10
|
modeToWrapper,
|
|
11
11
|
} from './codegen-contract.js';
|
|
12
|
-
import { renderOutputTsType, quoteKey, headerNameToProperty } from './ts-render.js';
|
|
12
|
+
import { renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, escapeSingleQuoted } from './ts-render.js';
|
|
13
13
|
import { basename, dirname, relative } from 'path';
|
|
14
14
|
|
|
15
15
|
// ─── Content-type helpers ──────────────────────────────────────────────────
|
|
@@ -217,7 +217,7 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
217
217
|
// JSDoc from description
|
|
218
218
|
const desc = op.description ?? route.description;
|
|
219
219
|
if (desc) {
|
|
220
|
-
lines.push(` * ${
|
|
220
|
+
for (const l of escapeJsDocLines(desc)) lines.push(` * ${l}`);
|
|
221
221
|
}
|
|
222
222
|
// Source location comment
|
|
223
223
|
const relFile = outPath ? relative(dirname(outPath), file) : file;
|
|
@@ -261,8 +261,8 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
261
261
|
}
|
|
262
262
|
if (op.signature) {
|
|
263
263
|
const sigArgs = op.signaturePolicy
|
|
264
|
-
? `'${op.signature}', { policy: '${op.signaturePolicy}' }`
|
|
265
|
-
: `'${op.signature}'`;
|
|
264
|
+
? `'${escapeSingleQuoted(op.signature)}', { policy: '${escapeSingleQuoted(op.signaturePolicy)}' }`
|
|
265
|
+
: `'${escapeSingleQuoted(op.signature)}'`;
|
|
266
266
|
middlewares.push(`requireSignature(${sigArgs})`);
|
|
267
267
|
}
|
|
268
268
|
const middlewareStr = middlewares.length > 0 ? `, ${middlewares.join(', ')},` : ',';
|
|
@@ -277,14 +277,14 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
277
277
|
// Body validation (request-side — use Input variants)
|
|
278
278
|
if (hasBody && op.request) {
|
|
279
279
|
if (isSingleMultipart) {
|
|
280
|
-
lines.push(` const multipartBody = ctx.
|
|
280
|
+
lines.push(` const multipartBody = ctx.parsedBody as MultipartBody;`);
|
|
281
281
|
lines.push('');
|
|
282
282
|
} else if (bodies.length === 1) {
|
|
283
|
-
lines.push(` const body = await parseAndValidate(ctx.
|
|
283
|
+
lines.push(` const body = await parseAndValidate(ctx.parsedBody, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
|
|
284
284
|
lines.push('');
|
|
285
285
|
} else if (bodies.every(b => bodyTypesStructurallyEqual(b.bodyType, bodies[0]!.bodyType))) {
|
|
286
286
|
// All declared MIMEs share the same body shape — single validation suffices
|
|
287
|
-
lines.push(` const body = await parseAndValidate(ctx.
|
|
287
|
+
lines.push(` const body = await parseAndValidate(ctx.parsedBody, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
|
|
288
288
|
lines.push('');
|
|
289
289
|
} else {
|
|
290
290
|
// Different body types per MIME — dispatch on Content-Type
|
|
@@ -298,9 +298,9 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
298
298
|
for (const b of bodies) {
|
|
299
299
|
lines.push(` case '${b.contentType}':`);
|
|
300
300
|
if (b.contentType === 'multipart/form-data') {
|
|
301
|
-
lines.push(` body = ctx.
|
|
301
|
+
lines.push(` body = ctx.parsedBody as MultipartBody;`);
|
|
302
302
|
} else {
|
|
303
|
-
lines.push(` body = await parseAndValidate(ctx.
|
|
303
|
+
lines.push(` body = await parseAndValidate(ctx.parsedBody, ${renderInputType(b.bodyType, modelsWithInput)});`);
|
|
304
304
|
}
|
|
305
305
|
lines.push(` break;`);
|
|
306
306
|
}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
resolveImportPath,
|
|
11
11
|
rootNeedsScalar,
|
|
12
12
|
} from './codegen-contract.js';
|
|
13
|
-
import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, JSON_VALUE_TYPE_DECL } from './ts-render.js';
|
|
13
|
+
import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
|
|
14
14
|
|
|
15
15
|
// ─── Public entry point ────────────────────────────────────────────────────
|
|
16
16
|
|
|
@@ -123,7 +123,7 @@ function generateComments(model: ModelNode, outPath?: string): string[] {
|
|
|
123
123
|
lines.push(` * @deprecated`);
|
|
124
124
|
}
|
|
125
125
|
if (model.description) {
|
|
126
|
-
lines.push(` * ${
|
|
126
|
+
for (const l of escapeJsDocLines(model.description)) lines.push(` * ${l}`);
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
const relPath = outPath ? relative(dirname(outPath), model.loc.file) : model.loc.file;
|
|
@@ -205,6 +205,18 @@ function generateVisibilityModel(model: ModelNode, outPath?: string, modelsWithI
|
|
|
205
205
|
|
|
206
206
|
// ─── Field rendering ──────────────────────────────────────────────────────
|
|
207
207
|
|
|
208
|
+
/** Prefix a field declaration with a JSDoc comment built from `@deprecated` / description parts,
|
|
209
|
+
* neutralizing any block-comment terminator and expanding embedded newlines into continuation lines. */
|
|
210
|
+
function withFieldJsDoc(jsdocParts: string[], line: string): string {
|
|
211
|
+
if (jsdocParts.length === 0) return line;
|
|
212
|
+
const contentLines = escapeJsDocLines(jsdocParts.join(' '));
|
|
213
|
+
if (contentLines.length === 1) {
|
|
214
|
+
return `/** ${contentLines[0]} */\n ${line}`;
|
|
215
|
+
}
|
|
216
|
+
const body = contentLines.map(l => ` * ${l}`).join('\n');
|
|
217
|
+
return `/**\n${body}\n */\n ${line}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
208
220
|
function renderField(field: FieldNode): string {
|
|
209
221
|
const opt = field.optional || field.default !== undefined ? '?' : '';
|
|
210
222
|
let typeStr = renderTsType(field.type);
|
|
@@ -213,10 +225,7 @@ function renderField(field: FieldNode): string {
|
|
|
213
225
|
const jsdocParts: string[] = [];
|
|
214
226
|
if (field.deprecated) jsdocParts.push('@deprecated');
|
|
215
227
|
if (field.description) jsdocParts.push(field.description);
|
|
216
|
-
|
|
217
|
-
return `/** ${jsdocParts.join(' ')} */\n ${line}`;
|
|
218
|
-
}
|
|
219
|
-
return line;
|
|
228
|
+
return withFieldJsDoc(jsdocParts, line);
|
|
220
229
|
}
|
|
221
230
|
|
|
222
231
|
function renderInputField(field: FieldNode, modelsWithInput: Set<string>): string {
|
|
@@ -227,10 +236,7 @@ function renderInputField(field: FieldNode, modelsWithInput: Set<string>): strin
|
|
|
227
236
|
const jsdocParts: string[] = [];
|
|
228
237
|
if (field.deprecated) jsdocParts.push('@deprecated');
|
|
229
238
|
if (field.description) jsdocParts.push(field.description);
|
|
230
|
-
|
|
231
|
-
return `/** ${jsdocParts.join(' ')} */\n ${line}`;
|
|
232
|
-
}
|
|
233
|
-
return line;
|
|
239
|
+
return withFieldJsDoc(jsdocParts, line);
|
|
234
240
|
}
|
|
235
241
|
|
|
236
242
|
// ─── Output (post-transform wire shape) ──────────────────────────────────
|
|
@@ -297,8 +303,5 @@ function renderOutputField(field: FieldNode, outputCase: 'camel' | 'snake' | 'pa
|
|
|
297
303
|
const jsdocParts: string[] = [];
|
|
298
304
|
if (field.deprecated) jsdocParts.push('@deprecated');
|
|
299
305
|
if (field.description) jsdocParts.push(field.description);
|
|
300
|
-
|
|
301
|
-
return `/** ${jsdocParts.join(' ')} */\n ${line}`;
|
|
302
|
-
}
|
|
303
|
-
return line;
|
|
306
|
+
return withFieldJsDoc(jsdocParts, line);
|
|
304
307
|
}
|
package/src/codegen-sdk.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { OpRootNode, OpRouteNode, OpOperationNode, OpRequestBodyNode, ContractTypeNode, ParamSource } from '@contractkit/core';
|
|
2
2
|
import { resolveModifiers, isJsonMime, classifyContentType } from '@contractkit/core';
|
|
3
|
-
import { renderInputTsType, renderOutputTsType, quoteKey, headerNameToProperty, JSON_VALUE_TYPE_DECL } from './ts-render.js';
|
|
3
|
+
import { renderInputTsType, renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
|
|
4
4
|
import { pascalToDotCase, typeNeedsScalar } from './codegen-contract.js';
|
|
5
5
|
import { bodyTypesStructurallyEqual } from './codegen-operation.js';
|
|
6
6
|
import { basename, dirname, relative } from 'path';
|
|
@@ -289,11 +289,12 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, file: string, o
|
|
|
289
289
|
const tags: string[] = [];
|
|
290
290
|
if (op.name) tags.push(`@name ${op.name}`);
|
|
291
291
|
if (desc) tags.push(`@description ${desc}`);
|
|
292
|
-
|
|
293
|
-
|
|
292
|
+
const contentLines = tags.flatMap(t => escapeJsDocLines(t));
|
|
293
|
+
if (contentLines.length === 1) {
|
|
294
|
+
lines.push(` /** ${contentLines[0]} */`);
|
|
294
295
|
} else {
|
|
295
296
|
lines.push(` /**`);
|
|
296
|
-
for (const
|
|
297
|
+
for (const l of contentLines) lines.push(` * ${l}`);
|
|
297
298
|
lines.push(` */`);
|
|
298
299
|
}
|
|
299
300
|
}
|
package/src/path-utils.ts
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
|
-
import { resolve, join, relative, dirname } from 'node:path';
|
|
1
|
+
import { resolve, join, relative, dirname, isAbsolute } from 'node:path';
|
|
2
2
|
import type { ContractRootNode, OpRootNode } from '@contractkit/core';
|
|
3
3
|
import { collectTypeRefs, collectPublicTypeNames } from '@contractkit/core';
|
|
4
4
|
|
|
5
5
|
export const TEMPLATE_VAR_RE = /\{\w+\}/;
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Guard against path traversal in output-path templates. Output-path template variables
|
|
9
|
+
* (`{area}`, `{dir}`, `{filename}`, `{name}`) can be sourced from a `.ck` file's
|
|
10
|
+
* `options { keys }` block, so a malicious value like `../../../tmp/x` could escape the
|
|
11
|
+
* plugin's output directory. After the final absolute path is computed, verify it stays
|
|
12
|
+
* within `baseOutDir`; otherwise throw.
|
|
13
|
+
*/
|
|
14
|
+
function assertWithinBase(baseOutDir: string, outPath: string): string {
|
|
15
|
+
const rel = relative(resolve(baseOutDir), resolve(outPath));
|
|
16
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`Refusing to emit outside output directory: resolved path "${outPath}" escapes "${baseOutDir}" (check options { keys } values used in output path templates)`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return outPath;
|
|
22
|
+
}
|
|
23
|
+
|
|
7
24
|
export function resolveTemplate(template: string, vars: Record<string, string>): string {
|
|
8
25
|
return template.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? `{${key}}`);
|
|
9
26
|
}
|
|
@@ -47,14 +64,14 @@ export function computeOpOutPath(
|
|
|
47
64
|
|
|
48
65
|
if (output && TEMPLATE_VAR_RE.test(output)) {
|
|
49
66
|
const resolved = resolveTemplate(output, { filename, dir: relDir, ext: 'ck', ...meta });
|
|
50
|
-
if (includesFilename(resolved)) return join(baseOutDir, resolved);
|
|
51
|
-
return join(baseOutDir, resolved, defaultName);
|
|
67
|
+
if (includesFilename(resolved)) return assertWithinBase(baseOutDir, join(baseOutDir, resolved));
|
|
68
|
+
return assertWithinBase(baseOutDir, join(baseOutDir, resolved, defaultName));
|
|
52
69
|
}
|
|
53
70
|
if (output) {
|
|
54
|
-
if (includesFilename(output)) return join(baseOutDir, output);
|
|
55
|
-
return join(baseOutDir, output, relDir, defaultName);
|
|
71
|
+
if (includesFilename(output)) return assertWithinBase(baseOutDir, join(baseOutDir, output));
|
|
72
|
+
return assertWithinBase(baseOutDir, join(baseOutDir, output, relDir, defaultName));
|
|
56
73
|
}
|
|
57
|
-
return join(baseOutDir, relDir, defaultName);
|
|
74
|
+
return assertWithinBase(baseOutDir, join(baseOutDir, relDir, defaultName));
|
|
58
75
|
}
|
|
59
76
|
|
|
60
77
|
export function computeContractOutPath(
|
|
@@ -86,14 +103,14 @@ export function computeSdkOutPath(
|
|
|
86
103
|
|
|
87
104
|
if (clientOutput && TEMPLATE_VAR_RE.test(clientOutput)) {
|
|
88
105
|
const resolved = resolveTemplate(clientOutput, { filename, dir: relDir, ext: 'ck', ...meta });
|
|
89
|
-
if (includesFilename(resolved)) return join(baseOutDir, resolved);
|
|
90
|
-
return join(baseOutDir, resolved, defaultOutName);
|
|
106
|
+
if (includesFilename(resolved)) return assertWithinBase(baseOutDir, join(baseOutDir, resolved));
|
|
107
|
+
return assertWithinBase(baseOutDir, join(baseOutDir, resolved, defaultOutName));
|
|
91
108
|
}
|
|
92
109
|
if (clientOutput) {
|
|
93
|
-
if (includesFilename(clientOutput)) return join(baseOutDir, clientOutput);
|
|
94
|
-
return join(baseOutDir, clientOutput, relDir, defaultOutName);
|
|
110
|
+
if (includesFilename(clientOutput)) return assertWithinBase(baseOutDir, join(baseOutDir, clientOutput));
|
|
111
|
+
return assertWithinBase(baseOutDir, join(baseOutDir, clientOutput, relDir, defaultOutName));
|
|
95
112
|
}
|
|
96
|
-
return join(baseOutDir, relDir, defaultOutName);
|
|
113
|
+
return assertWithinBase(baseOutDir, join(baseOutDir, relDir, defaultOutName));
|
|
97
114
|
}
|
|
98
115
|
|
|
99
116
|
/**
|
|
@@ -118,14 +135,14 @@ export function computeSdkAreaClientOutPath(area: string, rootDir: string, clien
|
|
|
118
135
|
if (clientOutput && TEMPLATE_VAR_RE.test(clientOutput)) {
|
|
119
136
|
const resolved = resolveTemplate(clientOutput, { filename, dir: '', ext: 'ck', area, subarea: '' });
|
|
120
137
|
const cleaned = fixHiddenSegment(resolved.replace(/\/+/g, '/').replace(/^\//, ''));
|
|
121
|
-
if (includesFilename(cleaned)) return join(baseOutDir, cleaned);
|
|
122
|
-
return join(baseOutDir, cleaned, `${filename}.client.ts`);
|
|
138
|
+
if (includesFilename(cleaned)) return assertWithinBase(baseOutDir, join(baseOutDir, cleaned));
|
|
139
|
+
return assertWithinBase(baseOutDir, join(baseOutDir, cleaned, `${filename}.client.ts`));
|
|
123
140
|
}
|
|
124
141
|
if (clientOutput) {
|
|
125
|
-
if (includesFilename(clientOutput)) return join(baseOutDir, clientOutput);
|
|
126
|
-
return join(baseOutDir, clientOutput, `${filename}.client.ts`);
|
|
142
|
+
if (includesFilename(clientOutput)) return assertWithinBase(baseOutDir, join(baseOutDir, clientOutput));
|
|
143
|
+
return assertWithinBase(baseOutDir, join(baseOutDir, clientOutput, `${filename}.client.ts`));
|
|
127
144
|
}
|
|
128
|
-
return join(baseOutDir, `${filename}.client.ts`);
|
|
145
|
+
return assertWithinBase(baseOutDir, join(baseOutDir, `${filename}.client.ts`));
|
|
129
146
|
}
|
|
130
147
|
|
|
131
148
|
export function computeSdkTypeOutPath(
|
|
@@ -144,11 +161,11 @@ export function computeSdkTypeOutPath(
|
|
|
144
161
|
|
|
145
162
|
if (TEMPLATE_VAR_RE.test(typeOutput)) {
|
|
146
163
|
const resolved = resolveTemplate(typeOutput, { filename, dir: relDir, ext: 'ck', ...meta });
|
|
147
|
-
if (includesFilename(resolved)) return join(baseOutDir, resolved);
|
|
148
|
-
return join(baseOutDir, resolved, defaultOutName);
|
|
164
|
+
if (includesFilename(resolved)) return assertWithinBase(baseOutDir, join(baseOutDir, resolved));
|
|
165
|
+
return assertWithinBase(baseOutDir, join(baseOutDir, resolved, defaultOutName));
|
|
149
166
|
}
|
|
150
|
-
if (includesFilename(typeOutput)) return join(baseOutDir, typeOutput);
|
|
151
|
-
return join(baseOutDir, typeOutput, relDir, defaultOutName);
|
|
167
|
+
if (includesFilename(typeOutput)) return assertWithinBase(baseOutDir, join(baseOutDir, typeOutput));
|
|
168
|
+
return assertWithinBase(baseOutDir, join(baseOutDir, typeOutput, relDir, defaultOutName));
|
|
152
169
|
}
|
|
153
170
|
|
|
154
171
|
export function generateBarrelFiles(contractPaths: string[]): { outPath: string; content: string }[] {
|
package/src/ts-render.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ContractTypeNode, FieldNode } from '@contractkit/core';
|
|
1
|
+
import type { ContractTypeNode, FieldNode, ScalarTypeNode } from '@contractkit/core';
|
|
2
2
|
|
|
3
3
|
export const JSON_VALUE_TYPE_DECL = 'export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };';
|
|
4
4
|
|
|
@@ -6,6 +6,18 @@ export function quoteKey(name: string): string {
|
|
|
6
6
|
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `'${name}'`;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
/** Escape text for safe inclusion inside a JSDoc block comment: neutralize the
|
|
10
|
+
* block-comment terminator sequence and split embedded newlines into separate
|
|
11
|
+
* ` * ` continuation lines. Returns the content lines (WITHOUT a leading prefix). */
|
|
12
|
+
export function escapeJsDocLines(text: string): string[] {
|
|
13
|
+
return text.replace(/\*\//g, '*\\/').split('\n');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Escape a string for inclusion inside a single-quoted TypeScript string literal. */
|
|
17
|
+
export function escapeSingleQuoted(s: string): string {
|
|
18
|
+
return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
19
|
+
}
|
|
20
|
+
|
|
9
21
|
/** Convert an HTTP header name (e.g. `preference-applied`, `X-Request-ID`, `ETag`) to camelCase for use as a JS property. */
|
|
10
22
|
export function headerNameToProperty(name: string): string {
|
|
11
23
|
const parts = name.split(/[-_]/).filter(Boolean);
|
|
@@ -37,9 +49,9 @@ export function renderTsType(type: ContractTypeNode): string {
|
|
|
37
49
|
case 'record':
|
|
38
50
|
return `Record<${renderTsType(type.key)}, ${renderTsType(type.value)}>`;
|
|
39
51
|
case 'enum':
|
|
40
|
-
return type.values.map(v => `'${v}'`).join(' | ');
|
|
52
|
+
return type.values.map(v => `'${escapeSingleQuoted(v)}'`).join(' | ');
|
|
41
53
|
case 'literal':
|
|
42
|
-
return typeof type.value === 'string' ? `'${type.value}'` : String(type.value);
|
|
54
|
+
return typeof type.value === 'string' ? `'${escapeSingleQuoted(type.value)}'` : String(type.value);
|
|
43
55
|
case 'union':
|
|
44
56
|
return type.members.map(renderTsType).join(' | ');
|
|
45
57
|
case 'discriminatedUnion':
|
|
@@ -57,7 +69,7 @@ export function renderTsType(type: ContractTypeNode): string {
|
|
|
57
69
|
}
|
|
58
70
|
}
|
|
59
71
|
|
|
60
|
-
function renderTsScalar(name:
|
|
72
|
+
function renderTsScalar(name: ScalarTypeNode['name']): string {
|
|
61
73
|
switch (name) {
|
|
62
74
|
case 'string':
|
|
63
75
|
case 'email':
|
|
@@ -72,6 +84,7 @@ function renderTsScalar(name: string): string {
|
|
|
72
84
|
case 'boolean':
|
|
73
85
|
return 'boolean';
|
|
74
86
|
case 'date':
|
|
87
|
+
case 'time':
|
|
75
88
|
case 'datetime':
|
|
76
89
|
case 'duration':
|
|
77
90
|
case 'interval':
|
|
@@ -86,8 +99,10 @@ function renderTsScalar(name: string): string {
|
|
|
86
99
|
return 'Blob';
|
|
87
100
|
case 'json':
|
|
88
101
|
return 'JsonValue';
|
|
89
|
-
default:
|
|
90
|
-
|
|
102
|
+
default: {
|
|
103
|
+
const _exhaustive: never = name;
|
|
104
|
+
throw new Error(`plugin-typescript: unmapped scalar '${String(_exhaustive)}' — add a case`);
|
|
105
|
+
}
|
|
91
106
|
}
|
|
92
107
|
}
|
|
93
108
|
|
|
@@ -26,6 +26,10 @@ describe('renderType', () => {
|
|
|
26
26
|
expect(renderType(scalarType('string'))).toBe('z.string()');
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
it('throws on an unmapped scalar name', () => {
|
|
30
|
+
expect(() => renderType({ kind: 'scalar', name: 'decimal' } as any)).toThrow(/unmapped scalar 'decimal'/);
|
|
31
|
+
});
|
|
32
|
+
|
|
29
33
|
it('renders z.string() with min/max', () => {
|
|
30
34
|
expect(renderType(scalarType('string', { min: 1, max: 100 }))).toBe('z.string().min(1).max(100)');
|
|
31
35
|
});
|
|
@@ -132,7 +132,7 @@ describe('generateOperation', () => {
|
|
|
132
132
|
it('generates body validation with parseAndValidate', () => {
|
|
133
133
|
const root = opRoot([opRoute('/users', [opOperation('post', { request: opRequest('CreateUser') })])]);
|
|
134
134
|
const output = generateOp(root);
|
|
135
|
-
expect(output).toContain('parseAndValidate(ctx.
|
|
135
|
+
expect(output).toContain('parseAndValidate(ctx.parsedBody, CreateUser)');
|
|
136
136
|
});
|
|
137
137
|
|
|
138
138
|
it('generates create service method for POST', () => {
|
|
@@ -172,7 +172,7 @@ describe('generateOperation', () => {
|
|
|
172
172
|
]),
|
|
173
173
|
]);
|
|
174
174
|
const output = generateOp(root);
|
|
175
|
-
expect(output).toContain('const body = await parseAndValidate(ctx.
|
|
175
|
+
expect(output).toContain('const body = await parseAndValidate(ctx.parsedBody, AuthRequest)');
|
|
176
176
|
expect(output).not.toContain('switch (ctx.request.type)');
|
|
177
177
|
});
|
|
178
178
|
|
|
@@ -191,8 +191,8 @@ describe('generateOperation', () => {
|
|
|
191
191
|
expect(output).toContain('switch (ctx.request.type)');
|
|
192
192
|
expect(output).toContain("case 'application/json':");
|
|
193
193
|
expect(output).toContain("case 'multipart/form-data':");
|
|
194
|
-
expect(output).toContain('body = ctx.
|
|
195
|
-
expect(output).toContain('body = await parseAndValidate(ctx.
|
|
194
|
+
expect(output).toContain('body = ctx.parsedBody as MultipartBody;');
|
|
195
|
+
expect(output).toContain('body = await parseAndValidate(ctx.parsedBody, UploadMeta)');
|
|
196
196
|
expect(output).toContain("import { MultipartBody } from '@maroonedsoftware/multipart';");
|
|
197
197
|
});
|
|
198
198
|
});
|
|
@@ -471,7 +471,7 @@ describe('generateOperation', () => {
|
|
|
471
471
|
const root = opRoot([opRoute('/uploads', [opOperation('post', { request: opRequest('Upload', 'multipart/form-data') })])]);
|
|
472
472
|
const output = generateOp(root);
|
|
473
473
|
expect(output).toContain("bodyParserMiddleware(['multipart'])");
|
|
474
|
-
expect(output).toContain('ctx.
|
|
474
|
+
expect(output).toContain('ctx.parsedBody as MultipartBody');
|
|
475
475
|
expect(output).toContain("import { MultipartBody } from '@maroonedsoftware/multipart';");
|
|
476
476
|
});
|
|
477
477
|
});
|
|
@@ -1110,6 +1110,14 @@ describe('renderTsType', () => {
|
|
|
1110
1110
|
it('maps json to JsonValue', () => {
|
|
1111
1111
|
expect(renderTsType(scalarType('json'))).toBe('JsonValue');
|
|
1112
1112
|
});
|
|
1113
|
+
|
|
1114
|
+
it('maps time to string', () => {
|
|
1115
|
+
expect(renderTsType(scalarType('time'))).toBe('string');
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
it('throws on an unmapped scalar name', () => {
|
|
1119
|
+
expect(() => renderTsType({ kind: 'scalar', name: 'decimal' } as any)).toThrow(/unmapped scalar 'decimal'/);
|
|
1120
|
+
});
|
|
1113
1121
|
});
|
|
1114
1122
|
|
|
1115
1123
|
it('renders tuple type', () => {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { generateContract, renderType } from '../src/codegen-contract.js';
|
|
2
|
+
import { generatePlainTypes } from '../src/codegen-plain-types.js';
|
|
3
|
+
import { generateOp } from '../src/codegen-operation.js';
|
|
4
|
+
import { renderTsType, escapeJsDocLines, escapeSingleQuoted } from '../src/ts-render.js';
|
|
5
|
+
import { computeOpOutPath, computeSdkOutPath, computeSdkTypeOutPath, computeSdkAreaClientOutPath } from '../src/path-utils.js';
|
|
6
|
+
import { field, model, contractRoot, enumType, scalarType, opRoot, opRoute, opOperation, opResponse } from './helpers.js';
|
|
7
|
+
|
|
8
|
+
// ─── Fix 1: JSDoc comment injection via descriptions ────────────────────────
|
|
9
|
+
|
|
10
|
+
describe('JSDoc comment injection (descriptions)', () => {
|
|
11
|
+
it('neutralizes `*/` in a model description (Zod codegen)', () => {
|
|
12
|
+
const root = contractRoot([model('Widget', [field('id', scalarType('uuid'))], { description: 'closes */ early' })]);
|
|
13
|
+
const out = generateContract(root);
|
|
14
|
+
// The raw terminator must not appear inside the generated comment.
|
|
15
|
+
expect(out).not.toContain('closes */ early');
|
|
16
|
+
expect(out).toContain('closes *\\/ early');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('splits a two-line model description into ` * ` continuation lines (Zod codegen)', () => {
|
|
20
|
+
const root = contractRoot([model('Widget', [field('id', scalarType('uuid'))], { description: 'line one\nline two' })]);
|
|
21
|
+
const out = generateContract(root);
|
|
22
|
+
expect(out).toContain(' * line one');
|
|
23
|
+
expect(out).toContain(' * line two');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('neutralizes `*/` in a model description (plain-types codegen)', () => {
|
|
27
|
+
const root = contractRoot([model('Widget', [field('id', scalarType('uuid'))], { description: 'closes */ early' })]);
|
|
28
|
+
const out = generatePlainTypes(root);
|
|
29
|
+
expect(out).not.toContain('closes */ early');
|
|
30
|
+
expect(out).toContain('closes *\\/ early');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('neutralizes `*/` in a field description (plain-types codegen)', () => {
|
|
34
|
+
const root = contractRoot([model('Widget', [field('id', scalarType('uuid'), { description: 'bad */ desc' })])]);
|
|
35
|
+
const out = generatePlainTypes(root);
|
|
36
|
+
expect(out).not.toContain('bad */ desc');
|
|
37
|
+
expect(out).toContain('bad *\\/ desc');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('splits a two-line field description into a multi-line JSDoc block (plain-types codegen)', () => {
|
|
41
|
+
const root = contractRoot([model('Widget', [field('id', scalarType('uuid'), { description: 'first\nsecond' })])]);
|
|
42
|
+
const out = generatePlainTypes(root);
|
|
43
|
+
expect(out).toContain('first');
|
|
44
|
+
expect(out).toContain('* second');
|
|
45
|
+
// No premature close on the same physical line as content.
|
|
46
|
+
expect(out).not.toMatch(/first\nsecond/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('neutralizes `*/` in an operation description (server codegen)', () => {
|
|
50
|
+
const root = opRoot([opRoute('/x', [opOperation('get', { description: 'op */ desc', responses: [opResponse(200)] })])]);
|
|
51
|
+
const out = generateOp(root);
|
|
52
|
+
expect(out).not.toContain('op */ desc');
|
|
53
|
+
expect(out).toContain('op *\\/ desc');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('escapeJsDocLines neutralizes terminators and splits newlines', () => {
|
|
57
|
+
expect(escapeJsDocLines('a */ b')).toEqual(['a *\\/ b']);
|
|
58
|
+
expect(escapeJsDocLines('a\nb')).toEqual(['a', 'b']);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ─── Fix 2: enum / literal value escaping ───────────────────────────────────
|
|
63
|
+
|
|
64
|
+
describe('enum value escaping', () => {
|
|
65
|
+
it('escapes double and single quotes in a Zod enum', () => {
|
|
66
|
+
const out = renderType(enumType('a"b', "c'd"));
|
|
67
|
+
expect(out).toBe('z.enum(["a\\"b", "c\'d"])');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('escapes single quotes in a TS union of enum values', () => {
|
|
71
|
+
const out = renderTsType(enumType('a"b', "c'd"));
|
|
72
|
+
expect(out).toBe("'a\"b' | 'c\\'d'");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('escapes single quotes in a TS string literal', () => {
|
|
76
|
+
expect(renderTsType({ kind: 'literal', value: "it's" })).toBe("'it\\'s'");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('escapeSingleQuoted escapes backslashes, quotes and newlines', () => {
|
|
80
|
+
expect(escapeSingleQuoted("a'b\\c\nd")).toBe("a\\'b\\\\c\\nd");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ─── Fix 3: signature interpolation escaping ────────────────────────────────
|
|
85
|
+
|
|
86
|
+
describe('signature escaping (server codegen)', () => {
|
|
87
|
+
it('escapes a single quote in the signature value', () => {
|
|
88
|
+
const root = opRoot([opRoute('/x', [opOperation('get', { signature: "sig'v", responses: [opResponse(200)] })])]);
|
|
89
|
+
const out = generateOp(root);
|
|
90
|
+
expect(out).toContain("requireSignature('sig\\'v')");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('escapes single quotes in signature and policy', () => {
|
|
94
|
+
const root = opRoot([
|
|
95
|
+
opRoute('/x', [opOperation('get', { signature: "sig'v", signaturePolicy: "pol'y", responses: [opResponse(200)] })]),
|
|
96
|
+
]);
|
|
97
|
+
const out = generateOp(root);
|
|
98
|
+
expect(out).toContain("requireSignature('sig\\'v', { policy: 'pol\\'y' })");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ─── Fix 4: path traversal via .ck-derived template vars ────────────────────
|
|
103
|
+
|
|
104
|
+
describe('path traversal containment', () => {
|
|
105
|
+
const base = '/project/out';
|
|
106
|
+
|
|
107
|
+
it('throws when a `.ck`-derived {area} escapes the base output dir', () => {
|
|
108
|
+
expect(() =>
|
|
109
|
+
computeOpOutPath('/project/contracts/a.ck', base, '{area}/{filename}.ts', '.ts', '/project/contracts', {
|
|
110
|
+
area: '../../../tmp/x',
|
|
111
|
+
}),
|
|
112
|
+
).toThrow(/Refusing to emit outside output directory/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('throws when a `.ck`-derived {filename} escapes the base output dir', () => {
|
|
116
|
+
expect(() =>
|
|
117
|
+
computeSdkTypeOutPath('/project/contracts/a.ck', base, '{filename}.ts', '/project/contracts', {
|
|
118
|
+
filename: '../../etc/passwd',
|
|
119
|
+
}),
|
|
120
|
+
).toThrow(/Refusing to emit outside output directory/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('throws for computeSdkOutPath traversal', () => {
|
|
124
|
+
expect(() =>
|
|
125
|
+
computeSdkOutPath('/project/contracts/a.ck', base, '{area}/{filename}.ts', '/project/contracts', {
|
|
126
|
+
area: '../../../evil',
|
|
127
|
+
}),
|
|
128
|
+
).toThrow(/Refusing to emit outside output directory/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('throws for computeSdkAreaClientOutPath traversal', () => {
|
|
132
|
+
expect(() => computeSdkAreaClientOutPath('../../../evil', base, '{area}/{filename}.client.ts')).toThrow(
|
|
133
|
+
/Refusing to emit outside output directory/,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('allows a normal in-base path', () => {
|
|
138
|
+
const p = computeOpOutPath('/project/contracts/a.ck', base, '{area}/{filename}.ts', '.ts', '/project/contracts', {
|
|
139
|
+
area: 'billing',
|
|
140
|
+
});
|
|
141
|
+
expect(p).toBe('/project/out/billing/a.ts');
|
|
142
|
+
});
|
|
143
|
+
});
|