anhedral 0.8.4 → 0.8.6

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.
Files changed (46) hide show
  1. package/README.md +16 -11
  2. package/assets/anhedral-cli-init-technical.svg +9 -9
  3. package/assets/anhedral-cli-init.svg +9 -9
  4. package/assets/security/LICENSES.txt +63 -0
  5. package/assets/security/audit-deps.mjs +295 -0
  6. package/assets/security/braces-regression.mjs +102 -0
  7. package/assets/security/braces@3.0.3.patch +285 -0
  8. package/assets/security/decode-uri-component@0.2.2.patch +149 -0
  9. package/assets/security/decoder-regression.mjs +37 -0
  10. package/assets/security/forge-regression.mjs +46 -0
  11. package/assets/security/manifest.json +53 -0
  12. package/assets/security/node-forge@1.4.0.patch +26 -0
  13. package/dist/bin.js +39 -2
  14. package/dist/capabilities.d.ts +11 -7
  15. package/dist/capabilities.js +26 -22
  16. package/dist/dependencies.d.ts +3 -2
  17. package/dist/dependencies.js +44 -40
  18. package/dist/evidence-validation.d.ts +2 -0
  19. package/dist/evidence-validation.js +18 -0
  20. package/dist/foundation.d.ts +6 -0
  21. package/dist/foundation.js +49 -0
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.js +1 -0
  24. package/dist/platforms/desktop.js +3 -3
  25. package/dist/platforms/extension.js +64 -94
  26. package/dist/platforms/mobile.js +1 -1
  27. package/dist/pnpm-policy.d.ts +6 -0
  28. package/dist/pnpm-policy.js +61 -0
  29. package/dist/progress-view.d.ts +57 -0
  30. package/dist/progress-view.js +69 -0
  31. package/dist/progress.d.ts +95 -0
  32. package/dist/progress.js +327 -0
  33. package/dist/project.d.ts +1 -0
  34. package/dist/readiness.d.ts +1 -0
  35. package/dist/readiness.js +7 -2
  36. package/dist/render.d.ts +4 -0
  37. package/dist/render.js +13 -0
  38. package/dist/security-backports.d.ts +3 -0
  39. package/dist/security-backports.js +21 -0
  40. package/dist/standard.d.ts +2 -0
  41. package/dist/standard.js +161 -98
  42. package/dist/transaction.js +34 -5
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/docs/application-stack-standard.md +85 -669
  46. package/package.json +18 -9
@@ -0,0 +1,102 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+
5
+ // Golden results were captured from the original registry package braces 3.0.3.
6
+ const fixtures = [
7
+ {"input":"{a,b}","options":{},"expected":{"create":"(a|b)","compile":"(a|b)","expand":["a","b"],"stringify":"{a,b}"},"main":["(a|b)"]},
8
+ {"input":"a/{b,c}/d","options":{},"expected":{"create":"a/(b|c)/d","compile":"a/(b|c)/d","expand":["a/b/d","a/c/d"],"stringify":"a/{b,c}/d"},"main":["a/(b|c)/d"]},
9
+ {"input":"{01..05}","options":{},"expected":{"create":"(0[1-5])","compile":"(0[1-5])","expand":["01","02","03","04","05"],"stringify":"{01..05}"},"main":["(0[1-5])"]},
10
+ {"input":"{-3..3}","options":{},"expected":{"create":"(-[1-3]|[0-3])","compile":"(-[1-3]|[0-3])","expand":["-3","-2","-1","0","1","2","3"],"stringify":"{-3..3}"},"main":["(-[1-3]|[0-3])"]},
11
+ {"input":"{10..2..2}","options":{},"expected":{"create":"(2|4|6|8|10)","compile":"(2|4|6|8|10)","expand":["10","8","6","4","2"],"stringify":"{10..2..2}"},"main":["(2|4|6|8|10)"]},
12
+ {"input":"{a..z..3}","options":{},"expected":{"create":"(a|d|g|j|m|p|s|v|y)","compile":"(a|d|g|j|m|p|s|v|y)","expand":["a","d","g","j","m","p","s","v","y"],"stringify":"{a..z..3}"},"main":["(a|d|g|j|m|p|s|v|y)"]},
13
+ {"input":"{a,{b,c}}","options":{},"expected":{"create":"(a|(b|c))","compile":"(a|(b|c))","expand":["a","b","c"],"stringify":"{a,{b,c}}"},"main":["(a|(b|c))"]},
14
+ {"input":"{,a,,b,}","options":{},"expected":{"create":"(|a|b|)","compile":"(|a|b|)","expand":["","a","","b",""],"stringify":"{,a,,b,}"},"main":["(|a|b|)"]},
15
+ {"input":"\\{literal\\}","options":{"keepEscaping":true},"expected":{"create":"\\{literal\\}","compile":"\\{literal\\}","expand":["\\{literal\\}"],"stringify":"\\{literal\\}"},"main":["\\{literal\\}"]},
16
+ {"input":"file[{}].js","options":{},"expected":{"create":"file[{}].js","compile":"file[{}].js","expand":["file[{}].js"],"stringify":"file[{}].js"},"main":["file[{}].js"]},
17
+ {"input":"(x){a,b}","options":{},"expected":{"create":"(x)(a|b)","compile":"(x)(a|b)","expand":["(x)a","(x)b"],"stringify":"(x){a,b}"},"main":["(x)(a|b)"]},
18
+ {"input":"${name}","options":{},"expected":{"create":"${name}","compile":"${name}","expand":["${name}"],"stringify":"${name}"},"main":["${name}"]},
19
+ {"input":"{unclosed","options":{"escapeInvalid":true},"expected":{"create":"\\{unclosed","compile":"\\{unclosed","expand":["{unclosed"],"stringify":"{unclosed"},"main":["\\{unclosed"]},
20
+ {"input":"\"{a,b}\"","options":{"keepQuotes":true},"expected":{"create":"\"{a,b}\"","compile":"\"{a,b}\"","expand":["\"{a,b}\""],"stringify":"\"{a,b}\""},"main":["\"{a,b}\""]},
21
+ {"input":"{a,a,,b}","options":{"nodupes":true,"noempty":true},"expected":{"create":"(a|a|b)","compile":"(a|a|b)","expand":["a","b"],"stringify":"{a,a,,b}"},"main":["(a|a|b)"]},
22
+ {"input":"user-{200..300}","options":{},"expected":{"create":"user-(20[0-9]|2[1-9][0-9]|300)","compile":"user-(20[0-9]|2[1-9][0-9]|300)","expand":["user-200","user-201","user-202","user-203","user-204","user-205","user-206","user-207","user-208","user-209","user-210","user-211","user-212","user-213","user-214","user-215","user-216","user-217","user-218","user-219","user-220","user-221","user-222","user-223","user-224","user-225","user-226","user-227","user-228","user-229","user-230","user-231","user-232","user-233","user-234","user-235","user-236","user-237","user-238","user-239","user-240","user-241","user-242","user-243","user-244","user-245","user-246","user-247","user-248","user-249","user-250","user-251","user-252","user-253","user-254","user-255","user-256","user-257","user-258","user-259","user-260","user-261","user-262","user-263","user-264","user-265","user-266","user-267","user-268","user-269","user-270","user-271","user-272","user-273","user-274","user-275","user-276","user-277","user-278","user-279","user-280","user-281","user-282","user-283","user-284","user-285","user-286","user-287","user-288","user-289","user-290","user-291","user-292","user-293","user-294","user-295","user-296","user-297","user-298","user-299","user-300"],"stringify":"user-{200..300}"},"main":["user-(20[0-9]|2[1-9][0-9]|300)"]},
23
+ {"input":"","options":{},"expected":{"create":[""],"compile":"","expand":[],"stringify":""},"main":[""]},
24
+ {"input":"x","options":{},"expected":{"create":["x"],"compile":"x","expand":["x"],"stringify":"x"},"main":["x"]},
25
+ {"input":"{..3}","options":{},"expected":{"create":"{..3}","compile":"{..3}","expand":["{..3}"],"stringify":"{..3}"},"main":["{..3}"]},
26
+ {"input":"{1..3}","options":{"step":2},"expected":{"create":"(1|3)","compile":"(1|3)","expand":["1","3"],"stringify":"{1..3}"},"main":["(1|3)"]}
27
+ ];
28
+
29
+ const normalize = value => JSON.parse(JSON.stringify(value, (key, item) => ['parent', 'prev'].includes(key) ? undefined : item));
30
+
31
+ const bounded = fn => assert.throws(fn, error => error instanceof RangeError && /safe resource limits/.test(error.message));
32
+
33
+ function verifyCompatibility(braces) {
34
+ for (const { input, options, expected, main } of fixtures) {
35
+ assert.deepEqual(braces(input, options), main, `braces ${input}`);
36
+ const parsed = braces.parse(input, options);
37
+ assert.equal(parsed.type, 'root');
38
+ assert.equal(parsed.input, input);
39
+ assert.equal(braces.stringify(parsed, options), expected.stringify);
40
+ for (const method of ['create', 'compile', 'expand', 'stringify']) {
41
+ assert.deepEqual(normalize(braces[method](input, options)), expected[method], `${method} ${input}`);
42
+ }
43
+ }
44
+ assert.deepEqual(braces(['{a,b}', '{1..3}'], { expand: true }), ['a', 'b', '1', '2', '3']);
45
+ assert.deepEqual(braces(['{a,a}', '{a,b}'], { expand: true, nodupes: true }), ['a', 'b']);
46
+ assert.throws(() => braces.expand('{1..1001}'), /range limit/);
47
+ assert.deepEqual(braces.expand('{1..3}', { rangeLimit: false }), ['1', '2', '3']);
48
+ assert.match(braces.compile('{1..1000000000}'), /./, 'Optimized numeric regex compilation remains available');
49
+ assert.equal(braces.expand('{1..100000}', { rangeLimit: false }).length, 100000);
50
+
51
+ }
52
+
53
+ function verifyNestedStrings(braces) {
54
+ for (const method of ['create', 'compile', 'expand', 'parse', 'stringify']) {
55
+ bounded(() => braces[method]('{'.repeat(4000) + 'a,b' + '}'.repeat(4000)));
56
+ bounded(() => braces[method]('('.repeat(4000) + 'x' + ')'.repeat(4000)));
57
+ }
58
+ }
59
+
60
+ function verifyAstBounds(braces) {
61
+ for (const method of ['compile', 'expand', 'stringify']) {
62
+ let ast = { type: 'text', value: 'x' };
63
+ for (let i = 0; i < 101; i++) ast = { type: 'root', nodes: [ast] };
64
+ bounded(() => braces[method](ast));
65
+ const cycle = { type: 'root', nodes: [] }; cycle.nodes.push(cycle);
66
+ bounded(() => braces[method](cycle));
67
+ const parentCycle = { type: 'root', nodes: [] }; parentCycle.parent = parentCycle;
68
+ bounded(() => braces[method](parentCycle));
69
+ const foreignParents = Array.from({ length: 201 }, () => {
70
+ let parent;
71
+ for (let i = 0; i < 100; i++) parent = { parent };
72
+ return { type: 'text', value: 'x', parent };
73
+ });
74
+ bounded(() => braces[method]({ type: 'root', nodes: foreignParents }));
75
+ const leaf = { type: 'text', value: 'x' };
76
+ const dag = { type: 'root', nodes: [leaf, leaf] };
77
+ assert.deepEqual(braces[method](dag), method === 'expand' ? ['xx'] : 'xx', `${method} accepts an acyclic shared node`);
78
+ bounded(() => braces[method]({ type: 'root', nodes: Array.from({ length: 20001 }, () => ({ type: 'text', value: 'x' })) }));
79
+ }
80
+ }
81
+
82
+ function verifyExpansionBounds(braces) {
83
+ for (const input of ['{1..100001}', '{100001..1}', '{-100001..-1}', '{a,b}'.repeat(17)]) {
84
+ bounded(() => braces.expand(input, { rangeLimit: false }));
85
+ }
86
+ bounded(() => braces.compile('{1..300000..2}'));
87
+ bounded(() => braces.expand(`{${'0'.repeat(200)}1..${'0'.repeat(200)}100000}`, { rangeLimit: false }));
88
+ bounded(() => braces.expand('{100000000000000000000..100000000000000000001}', { rangeLimit: false }));
89
+ bounded(() => braces(['{a,b}'.repeat(16), '{a,b}'.repeat(16)], { expand: true }));
90
+ bounded(() => braces(Array(100001).fill('a')));
91
+ }
92
+
93
+ export function verifyBraces(packageRoot) {
94
+ const require = createRequire(import.meta.url);
95
+ const braces = require(path.join(packageRoot, 'index.js'));
96
+ assert.equal(require(path.join(packageRoot, 'package.json')).version, '3.0.3');
97
+ verifyCompatibility(braces);
98
+ verifyNestedStrings(braces);
99
+ verifyAstBounds(braces);
100
+ verifyExpansionBounds(braces);
101
+ return { normalPatterns: fixtures.length, depthLimit: 100, nodeLimit: 20000, expansionLimit: 100000, characterLimit: 10485760 };
102
+ }
@@ -0,0 +1,285 @@
1
+ diff --git a/index.js b/index.js
2
+ --- a/index.js
3
+ +++ b/index.js
4
+ @@ -1,4 +1,6 @@
5
+ 'use strict';
6
+ +
7
+ +const limits = require('./lib/limits');
8
+
9
+ const stringify = require('./lib/stringify');
10
+ const compile = require('./lib/compile');
11
+ @@ -23,11 +25,18 @@
12
+ let output = [];
13
+
14
+ if (Array.isArray(input)) {
15
+ + if (input.length > limits.MAX_RESULTS) limits.fail();
16
+ + let chars = 0;
17
+ for (const pattern of input) {
18
+ const result = braces.create(pattern, options);
19
+ if (Array.isArray(result)) {
20
+ - output.push(...result);
21
+ + const size = limits.measure(result);
22
+ + chars += size.chars;
23
+ + if (output.length + size.count > limits.MAX_RESULTS || chars > limits.MAX_CHARS) limits.fail();
24
+ + for (const value of result) output.push(value);
25
+ } else {
26
+ + chars += String(result).length;
27
+ + if (output.length >= limits.MAX_RESULTS || chars > limits.MAX_CHARS) limits.fail();
28
+ output.push(result);
29
+ }
30
+ }
31
+ diff --git a/lib/compile.js b/lib/compile.js
32
+ --- a/lib/compile.js
33
+ +++ b/lib/compile.js
34
+ @@ -1,9 +1,12 @@
35
+ 'use strict';
36
+ +
37
+ +const limits = require('./limits');
38
+
39
+ const fill = require('fill-range');
40
+ const utils = require('./utils');
41
+
42
+ const compile = (ast, options = {}) => {
43
+ + limits.ast(ast);
44
+ const walk = (node, parent = {}) => {
45
+ const invalidBlock = utils.isInvalidBrace(parent);
46
+ const invalidNode = node.invalid === true && options.escapeInvalid === true;
47
+ @@ -38,6 +41,7 @@
48
+
49
+ if (node.nodes && node.ranges > 0) {
50
+ const args = utils.reduce(node.nodes);
51
+ + limits.range(args, options, true);
52
+ const range = fill(...args, { ...options, wrap: false, toRegex: true, strictZeros: true });
53
+
54
+ if (range.length !== 0) {
55
+ @@ -48,13 +52,14 @@
56
+ if (node.nodes) {
57
+ for (const child of node.nodes) {
58
+ output += walk(child, node);
59
+ + limits.string(output);
60
+ }
61
+ }
62
+
63
+ return output;
64
+ };
65
+
66
+ - return walk(ast);
67
+ + return limits.string(walk(ast));
68
+ };
69
+
70
+ module.exports = compile;
71
+ diff --git a/lib/expand.js b/lib/expand.js
72
+ --- a/lib/expand.js
73
+ +++ b/lib/expand.js
74
+ @@ -1,4 +1,6 @@
75
+ 'use strict';
76
+ +
77
+ +const limits = require('./limits');
78
+
79
+ const fill = require('fill-range');
80
+ const stringify = require('./stringify');
81
+ @@ -9,6 +11,7 @@
82
+
83
+ queue = [].concat(queue);
84
+ stash = [].concat(stash);
85
+ + limits.append(queue, stash, enclose);
86
+
87
+ if (!stash.length) return queue;
88
+ if (!queue.length) {
89
+ @@ -31,6 +34,7 @@
90
+ };
91
+
92
+ const expand = (ast, options = {}) => {
93
+ + limits.ast(ast);
94
+ const rangeLimit = options.rangeLimit === undefined ? 1000 : options.rangeLimit;
95
+
96
+ const walk = (node, parent = {}) => {
97
+ @@ -61,6 +65,7 @@
98
+ throw new RangeError('expanded array length exceeds range limit. Use options.rangeLimit to increase or disable the limit.');
99
+ }
100
+
101
+ + limits.range(args, options);
102
+ let range = fill(...args, options);
103
+ if (range.length === 0) {
104
+ range = stringify(node, options);
105
+ @@ -107,7 +112,9 @@
106
+ return queue;
107
+ };
108
+
109
+ - return utils.flatten(walk(ast));
110
+ + const result = utils.flatten(walk(ast));
111
+ + limits.measure(result);
112
+ + return result;
113
+ };
114
+
115
+ module.exports = expand;
116
+ diff --git a/lib/limits.js b/lib/limits.js
117
+ new file mode 100644
118
+ --- /dev/null
119
+ +++ b/lib/limits.js
120
+ @@ -0,0 +1,102 @@
121
+ +'use strict';
122
+ +
123
+ +// Anhedral's local 3.0.3 security backport. Limits cannot be disabled by options.
124
+ +const MAX_DEPTH = 100;
125
+ +const MAX_NODES = 20000;
126
+ +const MAX_RESULTS = 100000;
127
+ +const MAX_CHARS = 10 * 1024 * 1024;
128
+ +const fail = () => { throw new RangeError('Brace operation exceeds safe resource limits'); };
129
+ +const string = value => {
130
+ + if (String(value).length > MAX_CHARS) fail();
131
+ + return value;
132
+ +};
133
+ +
134
+ +const ast = root => {
135
+ + const work = [[root, 0, false]];
136
+ + const active = new Set();
137
+ + const parentDepths = new Map();
138
+ + let count = 0;
139
+ + let chars = 0;
140
+ + while (work.length) {
141
+ + const [node, depth, leaving] = work.pop();
142
+ + if (leaving) { active.delete(node); continue; }
143
+ + if (!node || (typeof node !== 'object' && typeof node !== 'function')) throw new TypeError('Expected an AST node');
144
+ + if (depth > MAX_DEPTH || ++count > MAX_NODES || active.has(node)) fail();
145
+ + chars += node.value ? String(node.value).length : 0;
146
+ + if (chars > MAX_CHARS) fail();
147
+ + // expand follows parent links independently of the child traversal.
148
+ + const parents = new Set();
149
+ + let parent = node;
150
+ + while (parent && !parentDepths.has(parent)) {
151
+ + if (parents.has(parent) || parents.size > MAX_DEPTH) fail();
152
+ + if (parentDepths.size + parents.size >= MAX_NODES) fail();
153
+ + parents.add(parent);
154
+ + parent = parent.parent;
155
+ + }
156
+ + let parentDepth = parent ? parentDepths.get(parent) : 0;
157
+ + for (const value of [...parents].reverse()) {
158
+ + if (++parentDepth > MAX_DEPTH + 1) fail();
159
+ + parentDepths.set(value, parentDepth);
160
+ + }
161
+ + if (node.nodes) {
162
+ + if (!Array.isArray(node.nodes)) throw new TypeError('Expected AST children');
163
+ + if (node.nodes.length > MAX_NODES || work.length + node.nodes.length > MAX_NODES * 2) fail();
164
+ + active.add(node);
165
+ + work.push([node, depth, true]);
166
+ + for (let i = node.nodes.length - 1; i >= 0; i--) work.push([node.nodes[i], depth + 1, false]);
167
+ + }
168
+ + }
169
+ +};
170
+ +
171
+ +const measure = value => {
172
+ + const work = [[value, 0, false]];
173
+ + const active = new Set();
174
+ + let count = 0;
175
+ + let chars = 0;
176
+ + let visits = 0;
177
+ + while (work.length) {
178
+ + const [item, depth, leaving] = work.pop();
179
+ + if (leaving) { active.delete(item); continue; }
180
+ + if (depth > MAX_DEPTH || ++visits > MAX_RESULTS + MAX_NODES) fail();
181
+ + if (Array.isArray(item)) {
182
+ + if (active.has(item) || item.length > MAX_RESULTS || work.length + item.length > MAX_RESULTS + MAX_NODES) fail();
183
+ + active.add(item);
184
+ + work.push([item, depth, true]);
185
+ + for (let i = item.length - 1; i >= 0; i--) work.push([item[i], depth + 1, false]);
186
+ + } else if (item !== undefined) {
187
+ + count++;
188
+ + chars += String(item).length;
189
+ + if (count > MAX_RESULTS || chars > MAX_CHARS) fail();
190
+ + }
191
+ + }
192
+ + return { count, chars };
193
+ +};
194
+ +
195
+ +const append = (queue, stash, enclose) => {
196
+ + const left = measure(queue);
197
+ + const right = measure(stash);
198
+ + const count = left.count ? left.count * right.count : right.count;
199
+ + const chars = left.count ? left.chars * right.count + right.chars * left.count : right.chars;
200
+ + if (count > MAX_RESULTS || chars + (enclose ? count * 2 : 0) > MAX_CHARS) fail();
201
+ +};
202
+ +
203
+ +// fill-range enumerates descending ranges too; rangeLimit:false must not bypass the cap.
204
+ +const range = (args, options, compiling = false) => {
205
+ + const [start, end] = args;
206
+ + const step = Math.max(Math.abs(Number(args[2] === undefined ? options.step || 1 : args[2])), 1);
207
+ + if (compiling && step === 1) return; // Numeric regex ranges do not allocate each member.
208
+ + let a = Number(start);
209
+ + let b = Number(end);
210
+ + if (!Number.isInteger(a) || !Number.isInteger(b)) {
211
+ + if (String(start).length !== 1 || String(end).length !== 1) return;
212
+ + a = String(start).charCodeAt(0);
213
+ + b = String(end).charCodeAt(0);
214
+ + }
215
+ + if (!Number.isSafeInteger(a) || !Number.isSafeInteger(b)) fail();
216
+ + if (!Number.isFinite(step)) return; // fill-range handles invalid steps.
217
+ + const count = Math.floor(Math.abs(b - a) / step) + 1;
218
+ + const width = Math.max(String(start).length, String(end).length, String(args[2] || '').length, 1);
219
+ + if (count > MAX_RESULTS || count * (width + (compiling ? 2 : 0)) > MAX_CHARS) fail();
220
+ +};
221
+ +
222
+ +module.exports = { MAX_DEPTH, MAX_RESULTS, MAX_CHARS, fail, string, ast, measure, append, range };
223
+ diff --git a/lib/parse.js b/lib/parse.js
224
+ --- a/lib/parse.js
225
+ +++ b/lib/parse.js
226
+ @@ -1,4 +1,6 @@
227
+ 'use strict';
228
+ +
229
+ +const limits = require('./limits');
230
+
231
+ const stringify = require('./stringify');
232
+
233
+ @@ -143,6 +145,7 @@
234
+ */
235
+
236
+ if (value === CHAR_LEFT_PARENTHESES) {
237
+ + if (stack.length >= limits.MAX_DEPTH) limits.fail();
238
+ block = push({ type: 'paren', nodes: [] });
239
+ stack.push(block);
240
+ push({ type: 'text', value });
241
+ @@ -195,6 +198,7 @@
242
+ */
243
+
244
+ if (value === CHAR_LEFT_CURLY_BRACE) {
245
+ + if (stack.length >= limits.MAX_DEPTH) limits.fail();
246
+ depth++;
247
+
248
+ const dollar = prev.value && prev.value.slice(-1) === '$' || block.dollar === true;
249
+ @@ -325,6 +329,7 @@
250
+ } while (stack.length > 0);
251
+
252
+ push({ type: 'eos' });
253
+ + limits.ast(ast);
254
+ return ast;
255
+ };
256
+
257
+ diff --git a/lib/stringify.js b/lib/stringify.js
258
+ --- a/lib/stringify.js
259
+ +++ b/lib/stringify.js
260
+ @@ -1,8 +1,11 @@
261
+ 'use strict';
262
+ +
263
+ +const limits = require('./limits');
264
+
265
+ const utils = require('./utils');
266
+
267
+ module.exports = (ast, options = {}) => {
268
+ + limits.ast(ast);
269
+ const stringify = (node, parent = {}) => {
270
+ const invalidBlock = options.escapeInvalid && utils.isInvalidBrace(parent);
271
+ const invalidNode = node.invalid === true && options.escapeInvalid === true;
272
+ @@ -22,11 +25,12 @@
273
+ if (node.nodes) {
274
+ for (const child of node.nodes) {
275
+ output += stringify(child);
276
+ + limits.string(output);
277
+ }
278
+ }
279
+ return output;
280
+ };
281
+
282
+ - return stringify(ast);
283
+ + return limits.string(stringify(ast));
284
+ };
285
+
@@ -0,0 +1,149 @@
1
+ diff --git a/index.js b/index.js
2
+ index 24e7db0cc55aa22d0f98358b73298067deb33b52..896c03cce865e681bc95ef0d0ed7d64b18488224 100644
3
+ --- a/index.js
4
+ +++ b/index.js
5
+ @@ -1,42 +1,122 @@
6
+ 'use strict';
7
+ -var token = '%[a-f0-9]{2}';
8
+ -var singleMatcher = new RegExp('(' + token + ')|([^%]+?)', 'gi');
9
+ -var multiMatcher = new RegExp('(' + token + ')+', 'gi');
10
+ +// Matches one or more consecutive percent-encoded bytes (e.g. `%C3%A5`).
11
+ +const token = '%[a-f0-9]{2}';
12
+ +const multiMatcher = new RegExp(`(${token})+`, 'gi');
13
+
14
+ -function decodeComponents(components, split) {
15
+ - try {
16
+ - // Try to decode the entire string first
17
+ - return [decodeURIComponent(components.join(''))];
18
+ - } catch (err) {
19
+ - // Do nothing
20
+ +const hexPair = /^[a-f\d]{2}$/i;
21
+ +
22
+ +// Read a `%XX` sequence at `position`, returning the byte value and where to continue scanning.
23
+ +function parsePercentByte(input, position) {
24
+ + if (input.codePointAt(position) !== 37 || position + 3 > input.length) {
25
+ + return;
26
+ }
27
+
28
+ - if (components.length === 1) {
29
+ - return components;
30
+ + const digits = input.slice(position + 1, position + 3);
31
+ +
32
+ + if (!hexPair.test(digits)) {
33
+ + return;
34
+ }
35
+
36
+ - split = split || 1;
37
+ + return {byte: Number.parseInt(digits, 16), next: position + 3};
38
+ +}
39
+ +
40
+ +/**
41
+ + * Return how many bytes a UTF-8 code point needs based on its lead byte.
42
+ + * Returns 0 for continuation bytes and other invalid lead bytes.
43
+ + */
44
+ +function utf8SequenceLength(byte) {
45
+ + if (byte <= 0x7F) {
46
+ + return 1;
47
+ + }
48
+
49
+ - // Split the array in 2 parts
50
+ - var left = components.slice(0, split);
51
+ - var right = components.slice(split);
52
+ + // Start at 0xC2 to exclude overlong 2-byte encodings (0xC0/0xC1).
53
+ + if (byte >= 0xC2 && byte <= 0xDF) {
54
+ + return 2;
55
+ + }
56
+
57
+ - return Array.prototype.concat.call([], decodeComponents(left), decodeComponents(right));
58
+ + if (byte >= 0xE0 && byte <= 0xEF) {
59
+ + return 3;
60
+ + }
61
+ +
62
+ + if (byte >= 0xF0 && byte <= 0xF4) {
63
+ + return 4;
64
+ + }
65
+ +
66
+ + return 0;
67
+ }
68
+
69
+ +function isContinuationByte(byte) {
70
+ + return byte >= 0x80 && byte <= 0xBF;
71
+ +}
72
+ +
73
+ +/**
74
+ + * Decode as much of `input` as possible without throwing.
75
+ + *Scans left-to-right in O(n), decoding valid UTF-8 runs and leaving the rest literal.
76
+ + */
77
+ function decode(input) {
78
+ try {
79
+ return decodeURIComponent(input);
80
+ - } catch (err) {
81
+ - var tokens = input.match(singleMatcher) || [];
82
+ + } catch {
83
+ + let output = '';
84
+ + let position = 0;
85
+ +
86
+ + while (position < input.length) {
87
+ + if (input.codePointAt(position) !== 37) {
88
+ + output += input.charAt(position);
89
+ + position++;
90
+ + continue;
91
+ + }
92
+ +
93
+ + const firstByte = parsePercentByte(input, position);
94
+ +
95
+ + // `%` not followed by two hex digits (e.g. `%` or `%G`).
96
+ + if (!firstByte) {
97
+ + output += input.charAt(position);
98
+ + position++;
99
+ + continue;
100
+ + }
101
+ +
102
+ + const sequenceLength = utf8SequenceLength(firstByte.byte);
103
+ +
104
+ + // Continuation byte or invalid lead byte — emit one `%XX` literally.
105
+ + if (sequenceLength === 0) {
106
+ + output += input.slice(position, position + 3);
107
+ + position += 3;
108
+ + continue;
109
+ + }
110
+
111
+ - for (var i = 1; i < tokens.length; i++) {
112
+ - input = decodeComponents(tokens, i).join('');
113
+ + let end = firstByte.next;
114
+ + let validSequence = true;
115
+ +
116
+ + for (let index = 1; index < sequenceLength; index++) {
117
+ + const nextByte = parsePercentByte(input, end);
118
+ +
119
+ + if (!nextByte || !isContinuationByte(nextByte.byte)) {
120
+ + validSequence = false;
121
+ + break;
122
+ + }
123
+ +
124
+ + end = nextByte.next;
125
+ + }
126
+ +
127
+ + if (validSequence) {
128
+ + const encodedSequence = input.slice(position, end);
129
+ +
130
+ + try {
131
+ + output += decodeURIComponent(encodedSequence);
132
+ + position = end;
133
+ + continue;
134
+ + } catch {
135
+ + // Invalid UTF-8 despite correct structure — emit the first byte literally.
136
+ + }
137
+ + }
138
+
139
+ - tokens = input.match(singleMatcher) || [];
140
+ + // Missing continuation bytes or decode failure — emit the first byte literally.
141
+ + output += input.slice(position, position + 3);
142
+ + position += 3;
143
+ }
144
+
145
+ - return input;
146
+ + return output;
147
+ }
148
+ }
149
+
@@ -0,0 +1,37 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { createRequire } from 'node:module';
4
+ import { join } from 'node:path';
5
+
6
+ // Upstream fa479da scanner backport retains the legacy CommonJS/plus API.
7
+ export async function verifyDecoder(packageRoot) {
8
+ const require = createRequire(join(packageRoot, 'package.json'));
9
+ const decode = require(join(packageRoot, 'index.js'));
10
+ assert.equal(typeof decode, 'function', 'Decoder CommonJS export changed');
11
+ for (const [input, expected] of [
12
+ ['', ''], ['a+b%2Bc', 'a b+c'], ['%25', '%'], ['%2525', '%25'],
13
+ ['st%C3%A5le%', 'ståle%'], ['%E4%BD%A0%E5%A5%BD', '你好'],
14
+ ['%F0%9F%98%80', '😀'], ['%F4%8F%BF%BF', '\u{10FFFF}'],
15
+ ['%C2', '\uFFFD'], ['%C2%B5%C2', 'µ\uFFFD'],
16
+ ['%FE%FF', '\uFFFD\uFFFD'], ['%FF%FE', '\uFFFD\uFFFD'],
17
+ ['%F0%9F%98', '%F0%9F%98'], ['%ED%A0%80', '%ED%A0%80'],
18
+ ['%C0%AF', '%C0%AF'], ['%F4%90%80%80', '%F4%90%80%80'],
19
+ ['%C3%5A%A5', '%C3Z%A5'], ['%G0%C3%A5%ab', '%G0å%ab'],
20
+ ['%84%D7%25%88%90', '%84%D7%%88%90'], ['%20%20%25%80', ' %%80'],
21
+ ]) assert.equal(decode(input), expected, `Decoder compatibility failure: ${input}`);
22
+ for (const value of [undefined, null, 1, {}, [], Symbol('x')]) {
23
+ assert.throws(() => decode(value), TypeError);
24
+ }
25
+ // Execute hostile runs in an isolated, hard-bounded process: the old recursive
26
+ // fallback exhausts its stack or CPU; the scanner leaves invalid bytes literal.
27
+ const script = `
28
+ const assert = require('node:assert/strict');
29
+ const decode = require(${JSON.stringify(join(packageRoot, 'index.js'))});
30
+ assert.equal(decode('%AB'.repeat(10000)), '%AB'.repeat(10000));
31
+ assert.equal(decode('%C3%5A%A5'.repeat(10000)), '%C3Z%A5'.repeat(10000));
32
+ assert.equal(decode('%E0%80%80'.repeat(10000)), '%E0%80%80'.repeat(10000));
33
+ `;
34
+ execFileSync(process.execPath, ['--input-type=commonjs', '--eval', script], {
35
+ timeout: 5000, maxBuffer: 128 * 1024, stdio: 'pipe',
36
+ });
37
+ }
@@ -0,0 +1,46 @@
1
+ import assert from 'node:assert/strict';
2
+ import { Buffer } from 'node:buffer';
3
+ import { createHash, generateKeyPairSync, privateEncrypt, constants } from 'node:crypto';
4
+ import { createRequire } from 'node:module';
5
+ import { join } from 'node:path';
6
+
7
+ // PR1152 validates nested DigestAlgorithm children; Anhedral also enforces empty
8
+ // DER NULL parameters. Fixture keys never leave memory.
9
+ export async function verifyForge(packageRoot) {
10
+ const require = createRequire(join(packageRoot, 'package.json'));
11
+ const forge = require(join(packageRoot, 'lib/index.js'));
12
+ const { publicKey, privateKey } = generateKeyPairSync('rsa', {
13
+ modulusLength: 2048,
14
+ publicExponent: 65537,
15
+ });
16
+ const key = forge.pki.publicKeyFromPem(publicKey.export({ type: 'spki', format: 'pem' }));
17
+ const digest = createHash('sha256').update('Anhedral RSA validation fixture').digest();
18
+ const asn1 = forge.asn1;
19
+ const node = (type, value, constructed = false) =>
20
+ asn1.create(asn1.Class.UNIVERSAL, type, constructed, value);
21
+ const oid = () => node(asn1.Type.OID, asn1.oidToDer(forge.oids.sha256).getBytes());
22
+ const nil = (value = '') => node(asn1.Type.NULL, value);
23
+ const garbage = () => node(asn1.Type.OCTETSTRING, 'unconsumed');
24
+ const verify = (algorithm, extraOuter = []) => {
25
+ const info = node(asn1.Type.SEQUENCE, [
26
+ node(asn1.Type.SEQUENCE, algorithm, true),
27
+ node(asn1.Type.OCTETSTRING, digest.toString('binary')),
28
+ ...extraOuter,
29
+ ], true);
30
+ const der = Buffer.from(asn1.toDer(info).getBytes(), 'binary');
31
+ // Use normal PKCS#1 padding, never Forge's internal padding bypass options.
32
+ const encoded = Buffer.concat([
33
+ Buffer.from([0, 1]), Buffer.alloc(256 - der.length - 3, 255), Buffer.from([0]), der,
34
+ ]);
35
+ const signature = privateEncrypt({ key: privateKey, padding: constants.RSA_NO_PADDING }, encoded);
36
+ return key.verify(digest.toString('binary'), signature.toString('binary'));
37
+ };
38
+ assert.equal(verify([oid(), nil()]), true, 'Valid RSA SHA-256 with NULL parameters rejected');
39
+ assert.equal(verify([oid()]), true, 'Valid RSA SHA-256 without parameters rejected');
40
+ for (const algorithm of [[oid(), nil(), garbage()], [oid(), garbage()], [oid(), nil('garbage')], [oid(), nil('\x00')]]) {
41
+ assert.throws(() => verify(algorithm), /valid RSASSA-PKCS1-v1_5 DigestInfo/,
42
+ 'RSA verification accepted malformed DigestAlgorithm parameters');
43
+ }
44
+ assert.throws(() => verify([oid(), nil()], [garbage()]), /valid RSASSA-PKCS1-v1_5 DigestInfo/,
45
+ 'RSA verification accepted an extra outer DigestInfo element');
46
+ }