1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552 | // A source lint over src/portofel, for the CLJS traps that each shipped a bug
// in this suite. Every check carries a "would have caught the bug that
// motivated it" probe beside it, because an unprobed gate is a claim, not a
// check.
//
// ---------------------------------------------------------------------------
// PROVENANCE. This is the scaffold's own, APP-AGNOSTIC copy of the lint whose
// canon lives in chat (chat/client/test/source-hygiene.test.mjs). Chat's copy
// additionally carries checks about CHAT — a façade freeze pinned to its
// `:testlib` exports and vtables, an externs.js cross-check with an exact
// count. On a fresh app those cannot pass, and the tempting fix — relaxing
// each until it goes green — produces a file full of gates that cannot fail.
// A gate that cannot fail is worse than no gate: it occupies the slot a real
// one would have. So the scaffold ships only what holds on day zero, and every
// check below passes on a fresh scaffold.
//
// WHEN YOU ADD A `:testlib` BUILD (test/README.md, "What to add"), add the
// façade freeze back with a real vector: helpers/facade.mjs is already here,
// and chat's copy of this file shows the canon shape (the exact-match test,
// the non-vacuousness probe, the by-name `:exports` resolution).
// ---------------------------------------------------------------------------
//
// THE CHECKS, and the bug behind each:
//
// 1-4 A LOCAL NAMED AFTER A CORE MACRO shadows it inside its own body, with no
// warning at any optimization level. In the odeon port, audio.cljs took
// the TypeScript's `when` (an AudioContext time) as a parameter, and
// `(when (nil? @NOISE) …)` became `when.call(null, …)`. Green build, green
// vectors, every noise voice throwing at runtime.
//
// 5-6 A `defn`/`def` WHOSE NAME IS A CORE VAR shadows it for the whole
// namespace. shadow emits a `:redef` warning but the build does not fail
// on warnings, so it scrolls past. valley-blocks' hud.cljs shipped a
// `(defn- reset! …)` that way, and banca's lib/protocol.cljs a
// `(defn- pos-int? …)` — which is why CORE_VARS below carries the core
// PREDICATES too. The list is a LIST, not a rule.
//
// 7 `#js {}` / `js-obj` PRESERVE LITERAL KEY ORDER ONLY TO EIGHT PAIRS. At
// nine they route through a PersistentHashMap and emit in HASH order, at
// every optimization level, silently. dev/docs/CLJS.md calls this the
// most dangerous trap in the migration. Forbidden outright rather than
// counted: everything goes through ardegazu.rooms.js/ordered.
//
// 8-11 A FORM WHOSE INDENTATION SAYS ONE THING AND WHOSE PAREN DEPTH SAYS
// ANOTHER. chat v24 shipped `do-replay` one paren short; the trailing
// `(.catch …)` became an argument rather than a link, which compiles to
// `function(c){…}.catch()` — valid ClojureScript, valid JavaScript, and a
// throw the instant boot touched it. No room UI was built, for every user,
// for two releases. Two independent detectors, because the defect leaves
// two independent traces.
//
// 12-18 THE XSS SURFACE, as an allowlist checked in BOTH directions. The
// scaffold's demo UI builds NODES (app/dom.cljs): `textContent` for
// every string, `createElement` for every element, no path through which
// a value can become markup. So the list is EMPTY and every check reads
// as an absolute: nothing under src/portofel may assign `.-innerHTML`,
// `.-outerHTML` or `insertAdjacentHTML`, define an `esc`, or hand a
// renderer an `:innerHTML` attribute. The empty list makes one direction
// vacuous; the probes below do the work it cannot, by running the
// detectors over code that DOES offend.
//
// It scans THIS repo's namespaces only. The shared rooms core
// (ardegazu.rooms.*) comes off the classpath from ardegazu-rooms-kit and is
// linted there, by that repo's copy of this file. Between the two, every .cljs
// this app compiles is covered — so if you ever move code the other way, ask
// what was scanning it yesterday and whether that still reaches.
import test from "node:test";
import assert from "node:assert/strict";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { readForms, strip, sym, COLLS } from "./helpers/cljs-reader.mjs";
const SRC = new URL("../src/portofel", import.meta.url).pathname;
const MACROS = new Set([
"when", "when-not", "when-let", "when-some", "when-first",
"if", "if-let", "if-some", "if-not", "cond", "condp", "case",
"let", "loop", "recur", "for", "doseq", "dotimes", "do", "and", "or", "while",
"try", "binding", "lazy-seq", "doto", "this-as", "declare", "fn", "defn",
"->", "->>", "some->", "some->>", "as->", "set!", "new", "var",
"time", "assert", "comment",
]);
const walk = (dir) =>
readdirSync(dir).flatMap((n) => {
const p = join(dir, n);
return statSync(p).isDirectory() ? walk(p) : p.endsWith(".cljs") ? [p] : [];
});
/** The balanced [...] that follows `from`, or null. */
function bracketAfter(src, from) {
const open = src.indexOf("[", from);
if (open < 0) return null;
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === "[") depth++;
else if (src[i] === "]" && --depth === 0) return src.slice(open + 1, i);
}
return null;
}
const TOKEN = /[A-Za-z*!?<>=+/_-][A-Za-z0-9*!?<>=+./'_-]*/g;
/** Top-level forms of a binding vector, metadata dropped. */
function forms(body) {
const out = [];
let depth = 0;
let cur = "";
for (const ch of body) {
if ("([{".includes(ch)) depth++;
if (")]}".includes(ch)) depth--;
if (depth === 0 && /\s/.test(ch)) {
if (cur) out.push(cur);
cur = "";
} else cur += ch;
}
if (cur) out.push(cur);
return out.filter((f) => f && !f.startsWith("^"));
}
test("no defn/fn parameter is named after a core macro", () => {
const hits = [];
for (const file of walk(SRC)) {
const src = strip(readFileSync(file, "utf8"));
for (const re of [/\(defn-?\s+[^\s[]+/g, /\(fn\s+(?:[^\s[]+\s+)?(?=\[)/g]) {
for (const m of src.matchAll(re)) {
const body = bracketAfter(src, m.index + m[0].length);
if (body === null) continue;
for (const t of body.match(TOKEN) ?? []) {
if (MACROS.has(t)) hits.push(`${file}:${src.slice(0, m.index).split("\n").length} param \`${t}\``);
}
}
}
}
assert.deepEqual(hits, [], `a parameter shadows a macro:\n${hits.join("\n")}`);
});
/**
* Every form in this tree whose head is a binding vector. The list is a LIST,
* not a rule, so it has to be extended whenever a new binding macro arrives —
* and the trap is that nothing announces the omission: an unlisted form is not
* an error, it is silence.
*
* `p/let` (promesa) is the live example. The regex is anchored on `(`
* immediately before the head, so `\(let\s` does NOT match `(p/let `: the moment
* a boot path stopped being a `js-await` ladder, the shadowing lint stopped
* covering it. That is the "when code moves, its gates do not follow" trap in
* its smallest possible form, and the fix is one string.
*/
const BINDING_FORMS = [
"let", "loop", "doseq", "dotimes", "if-let", "when-let", "if-some", "when-some", "js-await",
"p/let", "p/plet", "p/loop", "p/doseq",
];
test("no let/loop/doseq binding is named after a core macro", () => {
const hits = [];
for (const file of walk(SRC)) {
const src = strip(readFileSync(file, "utf8"));
for (const kind of BINDING_FORMS) {
for (const m of src.matchAll(new RegExp(`\\(${kind}\\s`, "g"))) {
const body = bracketAfter(src, m.index + m[0].length);
if (body === null) continue;
const names = forms(body).filter((_, i) => i % 2 === 0);
for (const nm of names) {
if (MACROS.has(nm)) hits.push(`${file}:${src.slice(0, m.index).split("\n").length} ${kind} binding \`${nm}\``);
}
}
}
}
assert.deepEqual(hits, [], `a binding shadows a macro:\n${hits.join("\n")}`);
});
test("the binding lint reaches promesa's binding forms too", () => {
// The regression this guards is not a shadowed name — it is a lint that
// quietly stopped looking. `(p/let [when …] …)` must be seen exactly as
// `(let [when …] …)` is, and `(let …)`'s own pattern must NOT be what sees it.
const sample = strip("(defn- f [] (p/let [when (g)] (when true 1)))");
const seen = (kind) => {
const m = [...sample.matchAll(new RegExp(`\\(${kind}\\s`, "g"))];
if (!m.length) return [];
const body = bracketAfter(sample, m[0].index + m[0][0].length);
return forms(body).filter((_, i) => i % 2 === 0).filter((nm) => MACROS.has(nm));
};
assert.deepEqual(seen("p/let"), ["when"], "the p/let pattern must see the binding");
assert.deepEqual(seen("let"), [], "the bare `let` pattern must NOT match `(p/let `");
assert.ok(BINDING_FORMS.includes("p/let"), "…so p/let has to be on the list");
});
test("the lint would have caught the bug that motivated it", () => {
// the exact shape audio.cljs shipped for one build
const sample = strip(`(defn- play-noise [when dur filt g0 tc]
(let [c @AC]
(when (nil? @NOISE) (vreset! NOISE (noise-buf c)))))`);
const m = [...sample.matchAll(/\(defn-?\s+[^\s[]+/g)][0];
const body = bracketAfter(sample, m.index + m[0].length);
assert.ok((body.match(TOKEN) ?? []).some((t) => MACROS.has(t)));
});
/** Core VARS (not macros) — a `defn`/`def` with one of these names shadows it
* for the entire namespace. Kept separate from MACROS above because the
* detection site is different: the def NAME, not a binding. */
const CORE_VARS = new Set([
"name", "type", "count", "key", "val", "keys", "vals", "first", "rest", "next", "last", "second",
"list", "map", "set", "get", "str", "seq", "meta", "range", "filter", "remove", "find", "sort",
"merge", "conj", "assoc", "dissoc", "into", "reduce", "apply", "concat", "reverse", "repeat",
"max", "min", "int", "long", "float", "double", "char", "boolean", "symbol", "keyword",
"reset!", "swap!", "deref", "atom", "delay", "force", "identity", "compare", "hash", "empty",
"print", "println", "pr", "prn", "format", "read", "eval", "load", "test", "some", "every?",
"replace", "partition", "group-by", "flatten", "take", "drop", "shuffle", "rand", "rand-int",
"array", "aget", "aset", "clone", "update", "peek", "pop", "push",
// The core PREDICATES. Added after `(defn- pos-int? …)` in banca's
// lib/protocol.cljs shadowed cljs.core/pos-int? and was caught by the
// compiler's `:redef` warning rather than by this gate — a warning that
// scrolls past in a large build. Every one of these is a natural name in a
// wire-validation namespace, which is precisely why they need to be here.
"pos-int?", "neg-int?", "nat-int?", "int?", "float?", "double?", "number?", "string?",
"boolean?", "ident?", "simple-ident?", "qualified-ident?", "uuid?", "inst?", "bytes?",
"object?", "array?", "fn?", "ifn?", "coll?", "seqable?", "indexed?", "counted?",
"nil?", "some?", "true?", "false?", "zero?", "pos?", "neg?", "even?", "odd?", "empty?",
]);
test("no defn/def shadows a core var", () => {
const hits = [];
for (const file of walk(SRC)) {
const src = strip(readFileSync(file, "utf8"));
for (const m of src.matchAll(/\((?:defn-?|def|defonce)\s+(?:\^[^\s]+\s+)*([^\s()[\]{}]+)/g)) {
const nm = m[1];
if (CORE_VARS.has(nm) || MACROS.has(nm)) {
hits.push(`${file}:${src.slice(0, m.index).split("\n").length} def \`${nm}\``);
}
}
}
assert.deepEqual(hits, [], `a def shadows a core var:\n${hits.join("\n")}`);
});
test("the core-var lint would have caught the bugs that motivated it", () => {
// (a) the exact shape valley-blocks' hud.cljs shipped for one build
const sample = strip(`(defn- reset! []\n (set! (.-textContent el) ""))`);
const m = [...sample.matchAll(/\((?:defn-?|def|defonce)\s+(?:\^[^\s]+\s+)*([^\s()[\]{}]+)/g)][0];
assert.equal(m[1], "reset!");
assert.ok(CORE_VARS.has(m[1]));
// (b) and banca's: a validator named after a core predicate. The compiler
// warned; the gate did not. It does now.
const pred = strip(`(defn- ^boolean pos-int? [n]\n (and (number? n) (>= n 1)))`);
const p = [...pred.matchAll(/\((?:defn-?|def|defonce)\s+(?:\^[^\s]+\s+)*([^\s()[\]{}]+)/g)][0];
assert.equal(p[1], "pos-int?");
assert.ok(CORE_VARS.has(p[1]), "a core predicate name must be caught by the LIST, not by a warning");
});
/**
* `#js {}` and `js-obj` preserve literal key order only up to EIGHT pairs. At
* nine or more they route through a PersistentHashMap and emit in HASH order,
* at every optimization level, with no warning — a structurally valid frame
* with scrambled keys. In a rooms app that is a wire bug: `canon()` sorts, so
* a scrambled preimage would still sign, and the transmitted bytes would
* differ from everyone else's. Rather than counting keys at every site, the
* construct is forbidden outright: every object goes through
* ardegazu.rooms.js/ordered, which sets keys with sequential unchecked-set
* calls regardless of width. A bare `(js-obj)` with no pairs is fine — there
* is no order to lose. `#js [...]` is fine too: an array has no keys.
*/
test("no #js {} or js-obj literal with pairs — everything goes through j/ordered", () => {
const hits = [];
for (const file of walk(SRC)) {
const src = strip(readFileSync(file, "utf8"));
for (const m of src.matchAll(/#js\s*\{/g)) {
hits.push(`${file}:${src.slice(0, m.index).split("\n").length} #js {…}`);
}
// `(js-obj)` with no arguments is the only accepted form.
//
// The `\s+` is a FIX, not a flourish: `\(js-obj[^)]` matches the first six
// characters of `(js-object? raw)` — a PREDICATE — and reported it as a
// nine-pair hazard. A lint that cries wolf gets its rule relaxed by the
// next person under time pressure, and then it stops catching the real
// thing.
for (const m of src.matchAll(/\(js-obj\s+[^)\s]/g)) {
hits.push(`${file}:${src.slice(0, m.index).split("\n").length} (js-obj …)`);
}
}
assert.deepEqual(hits, [], `use ardegazu.rooms.js/ordered instead:\n${hits.join("\n")}`);
});
test("the nine-pair lint would have caught the construct it bans", () => {
// The probe an absolute owes: run the detector over code that DOES offend.
// Nine pairs is where the switch happens, and this is what a frame builder
// written the natural way looks like.
const offender = strip('(def o #js {"v" 1 "t" "msg" "id" a "ch" b "body" c "seq" d "from" e "to" f "ctx" g})');
assert.equal([...offender.matchAll(/#js\s*\{/g)].length, 1);
const objForm = strip('(def o (js-obj "a" 1))');
assert.equal([...objForm.matchAll(/\(js-obj\s+[^)\s]/g)].length, 1);
// …and that the accepted forms stay silent: an empty `(js-obj)`, a `#js`
// ARRAY (no keys, so no order to lose), and j/ordered itself
const fine = strip('(def e (js-obj))\n(def a #js ["x" "y"])\n(def o (j/ordered "a" 1 "b" 2))');
assert.equal([...fine.matchAll(/#js\s*\{/g)].length, 0);
assert.equal([...fine.matchAll(/\(js-obj\s+[^)\s]/g)].length, 0);
// …and the FALSE POSITIVE the pattern used to have. `(js-object? x)` is a
// predicate, not a literal, and the old `\(js-obj[^)]` flagged it.
const predicate = strip("(defn- f [raw] (and (js-object? raw) (js-obj)))");
assert.equal([...predicate.matchAll(/\(js-obj\s+[^)\s]/g)].length, 0, "js-object? is not a js-obj literal");
assert.equal([...predicate.matchAll(/\(js-obj[^)]/g)].length, 1, "…which the old pattern got wrong");
});
/**
* A form whose INDENTATION says one thing and whose PAREN DEPTH says another.
*
* chat v24 shipped `do-replay` with its inner `.then` body closed one paren
* short. The trailing `(.catch …)` and `(.then …)` were indented as children of
* the enclosing `(-> …)` — laid out perfectly — but sat one level deeper, so
* they became extra ARGUMENTS to the `.then` above them instead of links in the
* chain, which compiles to a method call on the function literal itself.
* Functions have no `.catch`, so it threw the instant boot touched it. No room
* UI was built, for every user, for two releases. Nothing else caught it: the
* indentation was right, shadow compiled it without a murmur, both black-box
* suites stayed green.
*
* Two independent detectors, because the defect leaves two independent traces
* and a given instance may leave only one:
*
* · the LAYOUT trace — a sibling dedented below the one above it.
* · the CONSEQUENCE trace — an interop call whose target position holds a
* `fn` literal. Never valid, at any indentation, in any context.
*/
/** Every form's line-leading children, for the two checks below. */
function dedents(file, src) {
const hits = [];
const lines = src.split("\n");
for (const nd of readForms(src)) {
if (!COLLS.has(nd.type)) continue;
const kids = (nd.children ?? []).filter((k) => k.leading);
for (const k of kids.slice(1)) {
if (k.col < kids[0].col) {
hits.push(`${file}:${k.line} sits at column ${k.col}, its sibling at ${kids[0].line} at column ` +
`${kids[0].col} — ${(lines[k.line - 1] ?? "").trim().slice(0, 72)}`);
}
}
}
return hits;
}
test("no sibling is dedented below the one above it — layout must agree with depth", () => {
const hits = walk(SRC).flatMap((f) => dedents(f, readFileSync(f, "utf8")));
assert.deepEqual(hits, [], `indentation and paren depth disagree:\n${hits.join("\n")}`);
});
test("the indentation lint would have caught the bug that motivated it", () => {
// do-replay as v24 shipped it, reduced: the inner `.then` body is one paren
// short, so the two trailing links became arguments to it and dedented.
const sample = [
'(defn- do-replay [self]',
' (-> (js/Promise.resolve nil)',
' (.then',
' (fn [_]',
' (page 0))',
' (.catch (fn [err] nil))',
' (.then (fn [_] nil)))))',
].join("\n");
// one hit per dedented sibling: both trailing links moved
assert.equal(dedents("sample", sample).length, 2);
// and the same code with the paren where it belongs is silent
const fixed = [
'(defn- do-replay [self]',
' (-> (js/Promise.resolve nil)',
' (.then',
' (fn [_]',
' (page 0)))',
' (.catch (fn [err] nil))',
' (.then (fn [_] nil))))',
].join("\n");
assert.equal(dedents("sample", fixed).length, 0);
});
/** Forms that supply the interop target implicitly, and the child index from
* which they do it — `(-> x (.f))`, `(as-> x y (.f y))`, `(doto x (.f))`. */
const THREADS = { "->": 1, "->>": 1, "some->": 1, "some->>": 1, "cond->": 1, "cond->>": 1,
"doto": 1, "as->": 3, "..": 2 };
const FN_LITERAL = new Set(["fn", "fn*", "defn", "defn-"]);
/** `(.m (fn …))` / `(.-f (fn …))` outside a threading form: always fatal. */
function interopOnFn(file, src) {
const hits = [];
const lines = src.split("\n");
for (const nd of readForms(src)) {
const h = sym(nd);
if (!h || !h.startsWith(".") || h === "." || h === "..") continue;
const outer = nd.parent ? sym(nd.parent) : null;
const from = outer !== null && outer in THREADS ? THREADS[outer] : null;
if (from !== null && nd.parent.children.indexOf(nd) >= from) continue; // target is threaded in
const target = nd.children?.[1];
if (!target) continue;
if (target.type === "fn-literal" || FN_LITERAL.has(sym(target))) {
hits.push(`${file}:${nd.line} \`${h}\` is called on a function literal — ` +
`${(lines[nd.line - 1] ?? "").trim().slice(0, 72)}`);
}
}
return hits;
}
test("no interop call has a function literal in target position", () => {
const hits = walk(SRC).flatMap((f) => interopOnFn(f, readFileSync(f, "utf8")));
assert.deepEqual(hits, [], `a method is called on a function, which throws:\n${hits.join("\n")}`);
});
test("the interop lint would have caught the bug that motivated it", () => {
// the second instance of the same defect: a page loop's closing paren landed
// one form early, so `0` became a body form of `page` and `(.then (fn …))`
// became `page`'s argument.
const sample = [
'(-> ((fn page [n]',
' (if (>= n 200) nil (page (inc n)))',
' 0)',
' (.then (fn [_] nil))))',
].join("\n");
assert.equal(interopOnFn("sample", sample).length, 1);
// threaded, the identical text is correct — and must stay silent
const fixed = [
'(-> ((fn page [n]',
' (if (>= n 200) nil (page (inc n))))',
' 0)',
' (.then (fn [_] nil)))',
].join("\n");
assert.equal(interopOnFn("sample", fixed).length, 0);
});
/**
* THE XSS SURFACE, as an allowlist checked in BOTH directions.
*
* The scaffold's demo UI builds NODES (app/dom.cljs): `textContent` for every
* string, `createElement` for every element, and no path at all through which
* a value can become markup. So the list is EMPTY and every check below reads
* as an absolute: nothing under src/portofel may assign `.-innerHTML`,
* `.-outerHTML` or `insertAdjacentHTML`, define an `esc`, or hand a renderer
* an `:innerHTML` attribute.
*
* The list is a BUDGET, not a permit: an entry records the EXACT number of
* raw-HTML sinks and `esc` call sites a file may still contain, and both
* numbers may only ever fall. An empty list makes one direction of the check
* vacuous. It stays anyway — it is what makes ADDING an entry a deliberate act
* with a number attached rather than a permit — and the probes below do the
* work an empty list cannot, by running the detectors over code that DOES
* offend.
*/
const HTML_SINKS = ["-innerHTML", "-outerHTML", "insertAdjacentHTML"];
/** file (relative to src/portofel) -> {sinks, escCalls}, both exact. EMPTY on
* a fresh scaffold, and it may only grow on purpose, with a number. */
const STILL_IMPERATIVE = {};
const occurrences = (src, needle) => src.split(needle).length - 1;
/** Files under src/portofel, relative, that mention any raw-HTML sink. */
const sinkFiles = () =>
walk(SRC)
.filter((p) => HTML_SINKS.some((s) => strip(readFileSync(p, "utf8")).includes(s)))
.map((p) => p.slice(SRC.length + 1))
.sort();
/** Files that define an `esc` of their own (`(defn- esc` / `(defn esc`). */
const escFiles = () =>
walk(SRC)
.filter((p) => /\(defn-?\s+esc\b/.test(strip(readFileSync(p, "utf8"))))
.map((p) => p.slice(SRC.length + 1))
.sort();
/** {sinks, escCalls} in one source string. The gate and both probes below go
* through THIS, so a probe cannot pass against a counter the gate does not use. */
const countIn = (src) => ({
sinks: HTML_SINKS.reduce((n, s) => n + occurrences(src, s), 0),
escCalls: occurrences(src, "(esc "),
});
/** {sinks, escCalls} for one allowlisted file. */
const budgetOf = (rel) => countIn(strip(readFileSync(join(SRC, rel), "utf8")));
/** Allowlisted files whose budget for `what` is non-zero. */
const budgeted = (what) =>
Object.entries(STILL_IMPERATIVE).filter(([, b]) => b[what] > 0).map(([f]) => f).sort();
test("raw-HTML sinks live only in allowlisted files, and in all of them", () => {
assert.deepEqual(sinkFiles(), budgeted("sinks"));
});
test("`esc` is defined only in allowlisted files, and in all of them", () => {
// `budgeted("escCalls")`, not every allowlist key: the two offences do not
// always travel together — a file can interpolate values it trusts into
// sinks with no `esc` at all, and a gate that demanded an `esc` there would
// be demanding the file get WORSE.
assert.deepEqual(escFiles(), budgeted("escCalls"));
});
test("each allowlisted screen is within its exact raw-HTML budget", () => {
// EXACT, not "at most": a budget that only caught growth would let a screen
// convert and leave its allowance behind for the next person to spend.
const got = Object.fromEntries(Object.keys(STILL_IMPERATIVE).map((f) => [f, budgetOf(f)]));
assert.deepEqual(got, STILL_IMPERATIVE);
});
test("an understated budget FAILS — the counter really counts", () => {
// The probe an empty allowlist owes, executable rather than a claim in a
// comment. A file that offends four ways, budgeted at three: every
// understatement must be red, and zero must not be a free pass.
const offender = strip([
'(defn- esc [s] s)',
'(set! (.-innerHTML a) (str (esc (t "x")) (esc (t "y"))))',
'(.insertAdjacentHTML b "beforeend" (esc c))',
'(set! (.-outerHTML d) "")',
].join("\n"));
assert.deepEqual(countIn(offender), { sinks: 3, escCalls: 3 });
assert.notDeepEqual(countIn(offender), { sinks: 2, escCalls: 3 }, "an understated sink count must fail");
assert.notDeepEqual(countIn(offender), { sinks: 3, escCalls: 2 }, "an understated esc count must fail");
assert.notDeepEqual(countIn(offender), { sinks: 0, escCalls: 0 }, "zero must not be a free pass");
// and the allowlist mechanism itself: a file that offends must show up in the
// detectors, so adding an entry is the only way to make one pass
assert.ok(HTML_SINKS.some((s) => offender.includes(s)));
assert.ok(/\(defn-?\s+esc\b/.test(offender));
});
test("no hiccup node smuggles raw HTML past a renderer", () => {
// replicant (and friends) honour an `:innerHTML` ATTRIBUTE, which would put
// the whole escaping problem back exactly where it was — with none of the
// visible ugliness that makes string templates easy to find.
const offenders = walk(SRC).filter((p) => strip(readFileSync(p, "utf8")).includes(":innerHTML"));
assert.deepEqual(offenders, []);
});
test("the raw-HTML lint would have caught the code the demo replaced", () => {
// A lobby as the scaffold's demo wrote it before app/dom.cljs. Both halves
// of the check must fire on it: the sink and the escaper.
const before = [
'(defn- esc [s] (.replace ^string s (js/RegExp. "&" "g") "&"))',
'(set! (.-innerHTML overlay) (str "<p>" (esc (t "lobby.sub1")) "</p>"))',
].join("\n");
assert.ok(HTML_SINKS.some((s) => strip(before).includes(s)), "the sink must be seen");
assert.ok(/\(defn-?\s+esc\b/.test(strip(before)), "the escaper must be seen");
// …and neither may be found inside a string or a comment
const quoted = ';; (set! (.-innerHTML x) y)\n(def s "(defn- esc [s] s)")';
assert.ok(!HTML_SINKS.some((s) => strip(quoted).includes(s)));
assert.ok(!/\(defn-?\s+esc\b/.test(strip(quoted)));
});
test("the budget check counts, and is not fooled by a comment or a string", () => {
// The numbers have to come from a real count over real code, or the whole
// point of a budget is lost.
const sample = strip([
';; (set! (.-innerHTML a) (esc "x")) <- a comment, worth nothing',
'(def doc "(set! (.-innerHTML b) (esc 1))")',
'(set! (.-innerHTML c) (str (esc (t "a")) (esc (t "b"))))',
'(.insertAdjacentHTML d "beforeend" (esc e))',
].join("\n"));
assert.deepEqual(countIn(sample), { sinks: 2, escCalls: 3 });
});
|