-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvalidate-helpers-sync.js
More file actions
186 lines (162 loc) · 6.85 KB
/
Copy pathvalidate-helpers-sync.js
File metadata and controls
186 lines (162 loc) · 6.85 KB
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
#!/usr/bin/env node
/**
* Validate that the duplicated helper functions (sleep, get, showLog, log)
* are synchronized across all three userscripts.
*
* This script checks that:
* 1. All three files contain the "DUPLICATED HELPERS" comment block
* 2. The helper function signatures match (accounting for script-specific names like bd- vs gbd-)
* 3. The XSS-safety invariant (textContent only in log) is maintained in all copies
* 4. Userscripts with no helper block but the same documented no-innerHTML
* invariant (MCAS Auth Recovery) are checked file-wide for innerHTML
*
* Paths resolve against this file's directory, so it runs from any cwd.
*
* Usage: node validate-helpers-sync.js
*/
import { readFileSync } from 'fs';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
// Resolve against this file's own directory, not process.cwd(). Reading the
// userscripts by bare relative name meant the validator only ran from the repo
// root — `npm run check-sync` worked (npm sets cwd to the package root) while a
// direct `node path/to/validate-helpers-sync.js` died on ENOENT.
const repoRoot = dirname(fileURLToPath(import.meta.url));
const read = name => readFileSync(resolve(repoRoot, name), 'utf-8');
const scripts = [
{ name: 'Claude Bulk Deleter.user.js', prefix: 'bd' },
{ name: 'ChatGPT Bulk Deleter.user.js', prefix: 'bd' },
{ name: 'Gemini Bulk Deleter.user.js', prefix: 'gbd' },
];
// Userscripts that assert the no-innerHTML invariant in their own source but
// carry no DUPLICATED HELPERS block, so they cannot join `scripts` above.
// Without this list the claim in their header is enforced by nothing.
const xssOnlyScripts = ['MCAS Auth Recovery.user.js'];
let hasErrors = false;
// Returns the full source of `function <name>(...) { ... }` including its body,
// matched by counting braces to the real closing brace. Returns null if absent.
function extractFunctionBody(content, fnName) {
const header = new RegExp(`function\\s+${fnName}\\s*\\([^)]*\\)\\s*\\{`);
const m = content.match(header);
if (!m) return null;
const start = m.index;
let depth = 0;
for (let i = content.indexOf('{', start); i < content.length; i++) {
const ch = content[i];
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) return content.slice(start, i + 1);
}
}
return null; // unbalanced braces — treat as not extractable
}
// Check for DUPLICATED HELPERS comment in all three files
console.log('Checking for DUPLICATED HELPERS marker...');
scripts.forEach(({ name }) => {
const content = read(name);
if (!content.includes('DUPLICATED HELPERS')) {
console.error(`✗ ${name}: missing "DUPLICATED HELPERS" marker`);
hasErrors = true;
} else {
console.log(`✓ ${name}: found marker`);
}
});
// Check for XSS-safety invariant (textContent only, never innerHTML) in log()
console.log('\nChecking XSS-safety invariant in log()...');
scripts.forEach(({ name }) => {
const content = read(name);
// Extract the log function by brace-counting, NOT by regex. A lazy
// `\{[\s\S]*?\n\s*\}` stops at the close of the first nested block (the
// `if (pre) { ... }` inside log()), so everything after it went uninspected
// and an innerHTML added below that point passed this check clean — while
// CLAUDE.local.md calls this check the enforcement of the XSS invariant.
const logFn = extractFunctionBody(content, 'log');
if (logFn === null) {
console.error(`✗ ${name}: could not extract log() function`);
hasErrors = true;
return;
}
if (logFn.includes('innerHTML')) {
console.error(`✗ ${name}: log() function uses innerHTML (XSS risk!)`);
hasErrors = true;
} else if (logFn.includes('textContent')) {
console.log(`✓ ${name}: log() uses textContent only`);
} else {
console.error(`✗ ${name}: log() does not use either innerHTML or textContent`);
hasErrors = true;
}
});
// The three bulk deleters route every log line through log(); MCAS builds its
// banner inline instead, so the invariant there is file-wide rather than
// scoped to one function.
console.log('\nChecking no-innerHTML invariant in helper-less scripts...');
xssOnlyScripts.forEach(name => {
const content = read(name);
if (/\binnerHTML\b/.test(content.replace(/^\s*\/\/.*$/gm, ''))) {
console.error(`\u2717 ${name}: uses innerHTML (XSS risk!) \u2014 this file documents a textContent-only invariant`);
hasErrors = true;
} else {
console.log(`\u2713 ${name}: no innerHTML`);
}
});
// Check for required helper functions in all three, and that their signatures agree.
// Presence alone is not enough: a divergence in the parameter list of a hand-mirrored
// helper is exactly the drift this check exists to catch, and the README documents
// this as a signature comparison.
console.log('\nChecking for required helper functions...');
const requiredHelpers = ['sleep', 'get', 'showLog', 'log'];
// Returns the normalized parameter list of `helper` in `content`, or null if absent.
// Handles both `function name(args)` and `const name = (args) =>` / `const name = arg =>`.
function extractSignature(content, helper) {
const fnDecl = content.match(
new RegExp(`function\\s+${helper}\\s*\\(([^)]*)\\)`)
);
if (fnDecl) return normalizeParams(fnDecl[1]);
const arrow = content.match(
new RegExp(`const\\s+${helper}\\s*=\\s*(?:\\(([^)]*)\\)|([A-Za-z_$][\\w$]*))\\s*=>`)
);
if (arrow) return normalizeParams(arrow[1] !== undefined ? arrow[1] : arrow[2]);
return null;
}
function normalizeParams(params) {
return (params || '')
.split(',')
.map(p => p.trim())
.filter(Boolean)
.join(', ');
}
const signatures = new Map(); // helper -> [{ name, signature }]
scripts.forEach(({ name }) => {
const content = read(name);
requiredHelpers.forEach(helper => {
const signature = extractSignature(content, helper);
if (signature === null) {
console.error(`✗ ${name}: missing helper function "${helper}"`);
hasErrors = true;
return;
}
if (!signatures.has(helper)) signatures.set(helper, []);
signatures.get(helper).push({ name, signature });
});
});
console.log('\nChecking helper signatures match across copies...');
requiredHelpers.forEach(helper => {
const found = signatures.get(helper) || [];
if (found.length < scripts.length) return; // missing copies already reported above
const distinct = [...new Set(found.map(f => f.signature))];
if (distinct.length > 1) {
console.error(`✗ ${helper}(): signatures diverge across copies:`);
found.forEach(f => console.error(` ${f.name}: ${helper}(${f.signature})`));
hasErrors = true;
} else {
console.log(`✓ ${helper}(${distinct[0]}): identical in all ${found.length} copies`);
}
});
if (!hasErrors) {
console.log('\n✓ All sync checks passed!');
process.exit(0);
} else {
console.error('\n✗ Sync validation failed. See errors above.');
process.exit(1);
}