vector-mirror 1.0.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.
@@ -0,0 +1,541 @@
1
+ /**
2
+ * use_graph.js — D1c use-Graph-Amplifikations-Analyse (core-rein, REGEL-4)
3
+ * Vector Mirror v2.0
4
+ *
5
+ * maintainer method MD-LOOP: die SVG-`<use>`-Amplifikations-Logik (billion-laughs/
6
+ * DoS) ist als EIGENSTÄNDIGE, gehärtete, reine Komponente isoliert — statt im
7
+ * Renderer-Adapter weiter gepatcht zu werden. Diese Datei hat bewusst NULL
8
+ * Imports (REGEL-4: keine adapters/ oder interface/; keine node:*-I/O) — sie
9
+ * bekommt einen bereits GEPARSTEN DOM-Root als Argument und liefert ein reines
10
+ * DoS-Urteil. Determinismus: keine Date, kein Math.random, kein I/O, kein LLM.
11
+ *
12
+ * STRATEGISCHE WURZEL (3 Triple-Runden): statische Vorhersage der Browser-
13
+ * Render-Semantik ist fragil. Diese Komponente ist NUR best-effort-Frühabweisung
14
+ * — die Last-Resort-Schranke bleibt der echte Render (setContent-5s-Timeout) im
15
+ * Adapter. EHRLICHE ABDECKUNG (kein Over-Claiming): dieser STATISCHE Estimate ist
16
+ * die PRIMÄRE Schranke (forwarder-korrekt, ≤ MAX_USE_TOTAL_EXPANSION), der
17
+ * Timeout das Sicherheitsnetz für unbekannte Formen. Der post-render-Knoten-Cap
18
+ * im Adapter (querySelectorAll('*') > 500) zählt QUELL-DOM-Knoten, NICHT die
19
+ * use-Shadow-Expansion (SVG2 §5.6: Instanzen sind nicht im Light-DOM) — er ist
20
+ * KEINE Schranke gegen die gerenderte Instanz-Zahl. Deshalb gilt hier: lieber zu
21
+ * früh ablehnen als zu spät, NIE werfen, und JEDEN Pfad (Tiefe, Fan-out inkl.
22
+ * Forwarder-Ketten, Zyklus, Multi-svg, Deep-Nest, Budget) als kontrolliertes
23
+ * `{rejected:true}` enden lassen.
24
+ *
25
+ * Härtungs-Invarianten (jede aus einem konkreten Triple-Befund):
26
+ * - ITERATIV (explizite Stacks), NIE rekursiv → kein RangeError bei tiefem
27
+ * Nicht-use-`<g>`-Nest (3. Triple B1: depth-5000 warf ungefangen).
28
+ * - ALLE top-level `<svg>` zählen (nicht nur das erste) → kein Bypass durch
29
+ * eine Bombe im zweiten `<svg>` (3. Triple B2).
30
+ * - FORWARDER-HOP folgen: ein <use>, dessen Ziel selbst ein nacktes <use> ist,
31
+ * wird über die Forwarder-Kette zum echten Subtree-Root verfolgt (4. Triple:
32
+ * sonst Multiplikator verloren — CASE Y/X, ~122k/140k still durchgelassen).
33
+ * - HARTE Budgets (besuchte Knoten, Nest-Tiefe) → bricht jede pathologische
34
+ * Struktur kontrolliert ab, bevor sie teuer wird.
35
+ * - NEVER-THROW: jede Anomalie/Budget-Überschreitung → {rejected:true}.
36
+ */
37
+
38
+ // ── DoS-Schranken (SSOT für die use-Amplifikation) ───────────────────────────
39
+
40
+ /**
41
+ * Maximale same-document use-Verweis-Tiefe. KONSERVATIV: die gemessene Tiefe
42
+ * ZÄHLT die top-level-use→Target-Kante MIT (über die synthetische __root__-Kante),
43
+ * d.h. eine sichtbare Verschachtelung von N id-Containern ergibt Tiefe N+1.
44
+ * Bewusst so — lieber einen zu tiefen als einen zu flachen Graphen ablehnen,
45
+ * nie zu locker. ≤ 5 ist die ADR-L-005-Schranke.
46
+ */
47
+ export const MAX_USE_REFERENCE_DEPTH = 5;
48
+
49
+ /**
50
+ * Fan-out-Budget: geschätzte Gesamt-Instanz-Expansion des use-Graphen. Eine
51
+ * depth-5-Kette (von der Tiefe erlaubt) mit hohem Fan-out (z.B. fan-40 ≈ 40^4 ≈
52
+ * 2,5M Instanzen in ~3KB) rutscht am Tiefe-Cap vorbei → hier abgefangen. Über
53
+ * dieser Schwelle = Ablehnung. Großzügig für legitime Sprite-Komposition, hart
54
+ * gegen die Amplifikations-Bombe (early-bailout, kein vollständiges Aufzählen).
55
+ */
56
+ export const MAX_USE_TOTAL_EXPANSION = 100000;
57
+
58
+ /**
59
+ * Hartes Budget besuchter DOM-Knoten über den gesamten Lauf (alle svgs, alle
60
+ * Walks). Bricht jede pathologisch große/tiefe Struktur kontrolliert ab, BEVOR
61
+ * die Analyse selbst teuer wird (Schutz der Analyse, nicht nur des Renders).
62
+ */
63
+ export const MAX_GRAPH_NODES = 200000;
64
+
65
+ /**
66
+ * Hartes Nest-Tiefen-Budget (DOM-Verschachtelung, NICHT use-Referenz-Tiefe).
67
+ * Ein extrem tiefer Nicht-use-`<g>`-Nest (3. Triple B1) hat use-Tiefe 0, aber
68
+ * eine DOM-Tiefe, die eine rekursive Analyse hätte crashen lassen. Iterativ
69
+ * crasht nichts; ab dieser Schwelle lehnen wir trotzdem ab (kein legitimes SVG
70
+ * verschachtelt so tief — und der echte Render würde ohnehin leiden).
71
+ */
72
+ export const MAX_DOM_NEST_DEPTH = 1000;
73
+
74
+ /**
75
+ * canonicalizeFragment — repliziert Chromiums same-document `<use href>`-Auflösung
76
+ * als REINE Funktion (kein IO, kein Timing, never-throw), damit Analyzer UND
77
+ * href-Hook DASSELBE `#fragment` meinen (geteilte Kanon-SSOT).
78
+ *
79
+ * STRATEGISCHE WURZEL (S3/D1 R6): die Lücke war KEINE „vergessene Dekodierung",
80
+ * sondern eine ORAKEL-DIVERGENZ. Chromium löst `<use href>` über URL-Fragment-
81
+ * percent-decode → UTF-8 → `getElementById(decoded)` auf (SVG2 Kap.16; WHATWG-URL).
82
+ * Der Analyzer verglich vorher den ROHEN Fragment-String (`href.slice(1)`) gegen
83
+ * die bereits DEKODIERT gespeicherten `el.id`-Keys → jede Divergenz-Achse
84
+ * (percent/case/whitespace) verfehlte die Kante → Unterzählung → `rejected:false`,
85
+ * während Chromium die Bombe expandierte. Diese Funktion schließt genau diese
86
+ * Achse: sie liefert den DEKODIERTEN Fragment-String, der gegen die (ebenfalls
87
+ * dekodierten) `el.id`-Keys matcht.
88
+ *
89
+ * Regeln (gepinnt: 11/11 gegen reales Chromium, S3/D1-R6-Probe):
90
+ * 1. nicht mit `#` beginnend ODER `length < 2` → null
91
+ * 2. Fragment = Teil NACH dem ersten `#`
92
+ * 3. NUR TRAILING ASCII-Whitespace strippen (führend + intern BEHALTEN);
93
+ * Chromium strippt trailing (`#c0 ` → HIT auf `c0`), behält leading
94
+ * (`# c0` → MISS) und internen Space.
95
+ * 4. `decodeURIComponent` GENAU EINMAL in try/catch; bei Wurf (malformed `%`,
96
+ * z.B. `#%zz`) → den ROHEN Fragment-String zurückgeben (literal, NIE werfen).
97
+ * 5. CASE-SENSITIV matchen (kein case-fold, keine Unicode-Normalisierung).
98
+ *
99
+ * ⚠️ BEKANNTE REST-DIVERGENZ (gemischt valide/invalide Prozent, z.B.
100
+ * `#a%30%zzb`): `decodeURIComponent` ist ALL-OR-NOTHING — `%zz` lässt es werfen,
101
+ * der Fallback liefert den GANZEN rohen String `a%30%zzb`; Chromium dekodiert
102
+ * dagegen PER SEQUENZ → `a0%zzb` (HIT). Kanon → MISS, Chromium → HIT = Bypass-
103
+ * Klasse. Diese Achse schließt NICHT die Kanon, sondern der fail-closed-Riegel
104
+ * im href-Hook (jedes `%`-haltige Fragment → keepAttr=false, VOR dem Render). Die
105
+ * Kanon liefert die Parität für die `%`-freien Überlebenden (v.a. trailing-ws).
106
+ * KEINE der beiden allein ist sound — nur zusammen (hardening S3/D1).
107
+ *
108
+ * @param {string} href - der ROHE href/xlink:href-Attributwert (ungetrimmt).
109
+ * @returns {string|null} dekodiertes Fragment (gegen `el.id` matchbar) oder null.
110
+ */
111
+ export function canonicalizeFragment(href) {
112
+ // Regel 1: nur same-document `#fragment`. NIE werfen bei Nicht-String.
113
+ if (typeof href !== 'string' || href.length < 2 || href.charCodeAt(0) !== 35)
114
+ return null;
115
+ // Regel 2: Fragment = alles NACH dem ersten `#` (charCodeAt(0) ist `#` = 35).
116
+ let frag = href.slice(1);
117
+ // Regel 3: NUR trailing ASCII-Whitespace strippen (TAB/LF/FF/CR/SPACE).
118
+ // Führender + interner Whitespace bleibt (Chromium-Parität, Probe-gepinnt).
119
+ let end = frag.length;
120
+ while (end > 0) {
121
+ const c = frag.charCodeAt(end - 1);
122
+ if (c === 0x09 || c === 0x0a || c === 0x0c || c === 0x0d || c === 0x20)
123
+ end -= 1;
124
+ else break;
125
+ }
126
+ if (end !== frag.length) frag = frag.slice(0, end);
127
+ // Regel 4: decodeURIComponent GENAU EINMAL; bei malformed `%` → roher Frag-
128
+ // String (literal). never-throw — auch bei pathologischen Eingaben (überlange
129
+ // %-Sequenzen, isolierte Surrogate): decodeURIComponent wirft kontrolliert
130
+ // URIError, der hier gefangen wird; alles andere bliebe der äußere try/catch.
131
+ try {
132
+ return decodeURIComponent(frag);
133
+ } catch {
134
+ return frag;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Löst das `#fragment`-Target eines `<use>` auf die id-Map auf. Nutzt die geteilte
140
+ * Kanon-SSOT `canonicalizeFragment`, damit der Analyzer GENAU das Fragment auflöst,
141
+ * das Chromium beim Render auflöst (Orakel-Divergenz geschlossen). Die `byId`-Keys
142
+ * sind bereits dekodiert (`el.id`), daher matcht der dekodierte Kanon direkt.
143
+ * @param {Element} useEl
144
+ * @param {Map<string, Element>} byId
145
+ * @returns {Element|null}
146
+ */
147
+ function resolveUseTarget(useEl, byId) {
148
+ const rawHref =
149
+ useEl.getAttribute('href') || useEl.getAttribute('xlink:href') || '';
150
+ const target = canonicalizeFragment(rawHref);
151
+ if (target === null) return null;
152
+ return byId.get(target) || null;
153
+ }
154
+
155
+ /**
156
+ * Folgt einer Kette nackter `<use>`-Forwarder bis zum ERSTEN Nicht-use-Ziel
157
+ * (dem echten Subtree-Root) — UNTER demselben Pfad-Set-Zyklus-Guard. Ein nackter
158
+ * Forwarder (`<use id="f" href="#g"/>`) hat keine Element-Kinder; ohne dieses
159
+ * Chasing würde der multiplikative Abstieg an ihm enden und die Fan-out-Bombe
160
+ * unterzählen (4. Triple HIGH: `<use href="#fwd">` → fwd ist selbst `<use>` →
161
+ * tgt.children=0 → Multiplikator verloren).
162
+ *
163
+ * Markiert jede besuchte use-Ziel-id im `onPath`-Set (für die Zyklus-Erkennung
164
+ * über Forwarder-Ketten) und legt sie in `addedToPath` ab, damit der Aufrufer
165
+ * sie beim Frame-Pop wieder freigibt. Knoten-Budget wird pro Hop verbraucht.
166
+ *
167
+ * @param {Element} startTarget - das (evtl. Forwarder-)Ziel eines <use>.
168
+ * @param {Map<string, Element>} byId
169
+ * @param {Set<string>} onPath - aktuell offene use-Ziel-ids (Pfad-Set).
170
+ * @param {{nodesLeft:number}} budget
171
+ * @returns {{terminal:(Element|null), hops:number, cyclic:boolean,
172
+ * budgetExceeded:boolean, addedToPath:string[]}}
173
+ */
174
+ function chaseForwarders(startTarget, byId, onPath, budget) {
175
+ let node = startTarget;
176
+ let hops = 0;
177
+ const addedToPath = [];
178
+ let guard = 0;
179
+ while (node && (node.tagName || '').toLowerCase() === 'use') {
180
+ if (++guard > MAX_DOM_NEST_DEPTH) {
181
+ return {
182
+ terminal: null,
183
+ hops,
184
+ cyclic: true,
185
+ budgetExceeded: false,
186
+ addedToPath,
187
+ };
188
+ }
189
+ if (budget.nodesLeft <= 0) {
190
+ return {
191
+ terminal: null,
192
+ hops,
193
+ cyclic: false,
194
+ budgetExceeded: true,
195
+ addedToPath,
196
+ };
197
+ }
198
+ budget.nodesLeft -= 1;
199
+ const fid = node.id || '';
200
+ if (onPath.has(fid)) {
201
+ // Zyklus über Forwarder (z.B. f→g→f) → unendliche Expansion.
202
+ return {
203
+ terminal: null,
204
+ hops,
205
+ cyclic: true,
206
+ budgetExceeded: false,
207
+ addedToPath,
208
+ };
209
+ }
210
+ onPath.add(fid);
211
+ addedToPath.push(fid);
212
+ hops += 1; // der Forwarder-Knoten selbst zählt als 1 Instanz
213
+ const nextTarget = resolveUseTarget(node, byId);
214
+ if (!nextTarget) {
215
+ // Forwarder ohne auflösbares #fragment-Ziel → Kette endet hier.
216
+ return {
217
+ terminal: null,
218
+ hops,
219
+ cyclic: false,
220
+ budgetExceeded: false,
221
+ addedToPath,
222
+ };
223
+ }
224
+ node = nextTarget;
225
+ }
226
+ return {
227
+ terminal: node || null,
228
+ hops,
229
+ cyclic: false,
230
+ budgetExceeded: false,
231
+ addedToPath,
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Iterative längste use→Target-Kette über die id→id-Kanten (Tarjan-frei, expliziter
237
+ * Stack mit Farb-Markierung für Zyklus-Erkennung). NIE rekursiv.
238
+ *
239
+ * @param {Map<string, Set<string>>} edges
240
+ * @returns {{maxDepth:number}}
241
+ */
242
+ function computeMaxDepthIterative(edges) {
243
+ // 0 = ungesehen, 1 = auf dem Stack (grau), 2 = fertig (schwarz).
244
+ const color = new Map();
245
+ const depth = new Map(); // längste Kette ab Knoten (memoized)
246
+ let maxDepth = 0;
247
+
248
+ for (const start of edges.keys()) {
249
+ if (color.get(start) === 2) continue;
250
+ // Explizite DFS mit (node, childIterator)-Frames.
251
+ const stack = [
252
+ { node: start, it: (edges.get(start) || new Set()).values() },
253
+ ];
254
+ color.set(start, 1);
255
+ while (stack.length > 0) {
256
+ const frame = stack[stack.length - 1];
257
+ const next = frame.it.next();
258
+ if (next.done) {
259
+ // Alle Kinder fertig → längste Kette berechnen.
260
+ let best = 0;
261
+ const outs = edges.get(frame.node);
262
+ if (outs) {
263
+ for (const t of outs) {
264
+ const dt = depth.get(t) || 0;
265
+ if (dt + 1 > best) best = dt + 1;
266
+ }
267
+ }
268
+ depth.set(frame.node, best);
269
+ color.set(frame.node, 2);
270
+ if (best > maxDepth) maxDepth = best;
271
+ stack.pop();
272
+ continue;
273
+ }
274
+ const child = next.value;
275
+ const c = color.get(child) || 0;
276
+ if (c === 1) continue; // Rückkante — azyklische Länge weiterrechnen, NICHT Infinity
277
+ if (c === 2) continue; // schwarz = bereits fertig, memoized
278
+ color.set(child, 1);
279
+ stack.push({
280
+ node: child,
281
+ it: (edges.get(child) || new Set()).values(),
282
+ });
283
+ }
284
+ }
285
+ return { maxDepth };
286
+ }
287
+
288
+ /**
289
+ * Iterative Instanz-Expansions-Schätzung für EINEN Subtree-Root. KEINE Rekursion:
290
+ * ein expliziter Arbeits-Stack aus (element, childIterator)-Frames plus ein
291
+ * Pfad-Set (die aktuell „offenen" use-Ziele) für die Zyklus-Erkennung beim
292
+ * use→Target-Abstieg. Jeder besuchte Kind-Knoten zählt 1; ein `<use>`
293
+ * instanziiert zusätzlich sein Ziel (+1 Ziel-Wurzel + Subtree). Early-bailout bei
294
+ * `> expansionCap`. Hartes Knoten-/Tiefen-Budget bricht pathologische Strukturen
295
+ * kontrolliert ab.
296
+ *
297
+ * @param {Element} svgRoot
298
+ * @param {Map<string, Element>} byId
299
+ * @param {number} expansionCap
300
+ * @param {{nodesLeft:number}} budget - geteiltes, über alle svgs laufendes Knoten-Budget.
301
+ * @returns {{count:number, bailed:boolean, budgetExceeded:boolean,
302
+ * depthExceeded:boolean}}
303
+ */
304
+ function computeExpansionIterative(svgRoot, byId, expansionCap, budget) {
305
+ let count = 0;
306
+ let bailed = false;
307
+ let budgetExceeded = false;
308
+ let depthExceeded = false;
309
+
310
+ // Frame: { el, it (children-Iterator), pathIds (für Pfad-Set-Cleanup) }.
311
+ // Das Pfad-Set (onPath) enthält die use-Ziel-/Forwarder-ids, deren Subtree
312
+ // gerade EXPANDIERT wird (für die multiplikative Zyklus-Erkennung beim
313
+ // use→Target-Abstieg — inkl. Forwarder-Ketten). Ein Frame kann MEHRERE ids
314
+ // tragen (Forwarder-Hops + Terminal), die beim Pop gemeinsam freigegeben werden.
315
+ const onPath = new Set();
316
+ const stack = [
317
+ { el: svgRoot, it: svgRoot.children[Symbol.iterator](), pathIds: null },
318
+ ];
319
+
320
+ while (stack.length > 0) {
321
+ if (stack.length > MAX_DOM_NEST_DEPTH) {
322
+ depthExceeded = true;
323
+ break;
324
+ }
325
+ const frame = stack[stack.length - 1];
326
+ const next = frame.it.next();
327
+ if (next.done) {
328
+ if (frame.pathIds) for (const id of frame.pathIds) onPath.delete(id);
329
+ stack.pop();
330
+ continue;
331
+ }
332
+ const child = next.value;
333
+ if (budget.nodesLeft <= 0) {
334
+ budgetExceeded = true;
335
+ break;
336
+ }
337
+ budget.nodesLeft -= 1;
338
+ count += 1; // der Kind-Knoten selbst = 1 Instanz
339
+ if (count > expansionCap) {
340
+ bailed = true;
341
+ break;
342
+ }
343
+ const tag = (child.tagName || '').toLowerCase();
344
+ if (tag === 'use') {
345
+ const tgt = resolveUseTarget(child, byId);
346
+ if (tgt) {
347
+ // 4. Triple-FIX: das Ziel kann SELBST ein nackter <use>-Forwarder sein
348
+ // (oder eine Forwarder-Kette). chaseForwarders folgt der Kette bis zum
349
+ // ersten Nicht-use-Subtree-Root, zählt jeden Hop, guarded Zyklen über das
350
+ // gemeinsame onPath-Set. OHNE das endete der Abstieg am kinderlosen
351
+ // Forwarder → Fan-out-Multiplikator verloren (CASE Y/X, ~125k/140k still).
352
+ const chase = chaseForwarders(tgt, byId, onPath, budget);
353
+ if (chase.budgetExceeded) {
354
+ budgetExceeded = true;
355
+ break;
356
+ }
357
+ if (chase.cyclic) {
358
+ for (const id of chase.addedToPath) onPath.delete(id);
359
+ continue;
360
+ }
361
+ // Jeder Forwarder-Hop ist eine instanziierte Wurzel (mind. 1 Instanz).
362
+ count += chase.hops;
363
+ if (count > expansionCap) {
364
+ // onPath-Cleanup der gerade hinzugefügten Forwarder-ids (Bombe → bail).
365
+ for (const id of chase.addedToPath) onPath.delete(id);
366
+ bailed = true;
367
+ break;
368
+ }
369
+ const terminal = chase.terminal;
370
+ if (terminal) {
371
+ const tid = terminal.id || '';
372
+ if (onPath.has(tid)) {
373
+ for (const id of chase.addedToPath) onPath.delete(id);
374
+ continue;
375
+ }
376
+ count += 1; // die instanziierte terminale Ziel-Wurzel
377
+ if (count > expansionCap) {
378
+ for (const id of chase.addedToPath) onPath.delete(id);
379
+ bailed = true;
380
+ break;
381
+ }
382
+ onPath.add(tid);
383
+ // Frame trägt ALLE auf diesem Hop hinzugefügten Pfad-ids (Forwarder +
384
+ // Terminal), damit der Pop sie gemeinsam freigibt.
385
+ stack.push({
386
+ el: terminal,
387
+ it: terminal.children[Symbol.iterator](),
388
+ pathIds: [...chase.addedToPath, tid],
389
+ });
390
+ } else if (chase.addedToPath.length > 0) {
391
+ // Forwarder-Kette endete ohne Nicht-use-Terminal (z.B. Sackgasse) →
392
+ // die Forwarder-ids wieder freigeben (kein Frame trägt sie).
393
+ for (const id of chase.addedToPath) onPath.delete(id);
394
+ }
395
+ }
396
+ // <use> selbst hat keine geometrie-tragenden Kinder, die wir zählen.
397
+ } else {
398
+ // Normaler Container/Leaf — in seinen Subtree absteigen.
399
+ stack.push({
400
+ el: child,
401
+ it: child.children[Symbol.iterator](),
402
+ pathIds: null,
403
+ });
404
+ }
405
+ }
406
+
407
+ return { count, bailed, budgetExceeded, depthExceeded };
408
+ }
409
+
410
+ /**
411
+ * Sammelt die zu analysierenden Subtree-Roots: ALLE top-level `<svg>`. Der
412
+ * sanitisierte DOM kommt als Body-Wrapper (DOMPurify RETURN_DOM) ODER als
413
+ * einzelnes `<svg>`. Multi-svg (mehrere Geschwister-svgs) ist der 3.-Triple-B2-
414
+ * Bypass — wir zählen JEDES. Fällt auf alle deszendenten `<svg>` zurück, falls
415
+ * keine direkten gefunden werden (defensiv, deckt unerwartete Wrapper-Schichten).
416
+ * @param {Element} root
417
+ * @returns {Element[]}
418
+ */
419
+ function collectSvgRoots(root) {
420
+ if ((root.tagName || '').toLowerCase() === 'svg') return [root];
421
+ const direct = [];
422
+ for (const child of root.children) {
423
+ if ((child.tagName || '').toLowerCase() === 'svg') direct.push(child);
424
+ }
425
+ if (direct.length > 0) return direct;
426
+ // Defensiv: keine direkten svg-Kinder → alle deszendenten svgs (nie weniger
427
+ // abdecken als der frühere querySelector — aber jetzt ALLE, nicht nur das erste).
428
+ return [...root.querySelectorAll('svg')];
429
+ }
430
+
431
+ /**
432
+ * analyzeUseGraph — das gehärtete reine DoS-Urteil über den use-Graphen.
433
+ *
434
+ * WIRFT NIE. Bei jeder Anomalie (kein gültiger Root, interner Fehler) oder
435
+ * Schranken-/Budget-Überschreitung → `{rejected:true, reason:'SECURITY_VIOLATION'}`.
436
+ * Der Caller (resolve) macht daraus den dokumentierten `{error:'SECURITY_VIOLATION'}`.
437
+ *
438
+ * @param {Element} root - sanitisierte DOM-Wurzel (Body-Wrapper o. <svg>).
439
+ * @param {{maxDepth?:number, maxExpansion?:number, maxNodes?:number}} [opts]
440
+ * @returns {{maxDepth:number, totalExpansion:number, cyclic:boolean,
441
+ * rejected:boolean, reason:(string|null)}}
442
+ */
443
+ export function analyzeUseGraph(root, opts = {}) {
444
+ const maxDepthCap = opts.maxDepth ?? MAX_USE_REFERENCE_DEPTH;
445
+ const expansionCap = opts.maxExpansion ?? MAX_USE_TOTAL_EXPANSION;
446
+ const nodeBudget = opts.maxNodes ?? MAX_GRAPH_NODES;
447
+
448
+ const SAFE = {
449
+ maxDepth: 0,
450
+ totalExpansion: 0,
451
+ cyclic: false,
452
+ rejected: false,
453
+ reason: null,
454
+ };
455
+ const REJECT = (reason) => ({
456
+ maxDepth: Number.POSITIVE_INFINITY,
457
+ totalExpansion: Number.POSITIVE_INFINITY,
458
+ cyclic: false,
459
+ rejected: true,
460
+ reason: reason || 'SECURITY_VIOLATION',
461
+ });
462
+
463
+ try {
464
+ if (!root || typeof root.querySelectorAll !== 'function') return SAFE;
465
+
466
+ // id → Element-Map (erste id gewinnt, wie der Browser bei dup-ids).
467
+ const byId = new Map();
468
+ let idCount = 0;
469
+ for (const el of root.querySelectorAll('[id]')) {
470
+ idCount += 1;
471
+ if (idCount > nodeBudget) return REJECT('USE_GRAPH_NODE_BUDGET');
472
+ if (!byId.has(el.id)) byId.set(el.id, el);
473
+ }
474
+
475
+ // ── (1) maxDepth über die id→id-Kanten (iterativ, Zyklus-erkennend) ──────
476
+ const edges = new Map();
477
+ const addEdge = (from, to) => {
478
+ if (!edges.has(from)) edges.set(from, new Set());
479
+ edges.get(from).add(to);
480
+ };
481
+ let useCount = 0;
482
+ for (const useEl of root.querySelectorAll('use')) {
483
+ useCount += 1;
484
+ if (useCount > nodeBudget) return REJECT('USE_GRAPH_NODE_BUDGET');
485
+ const rawHref =
486
+ useEl.getAttribute('href') || useEl.getAttribute('xlink:href') || '';
487
+ // Geteilte Kanon-SSOT: dasselbe `#fragment`, das Chromium auflöst (kein
488
+ // Roh-vs-dekodiert-Vergleich mehr → Orakel-Divergenz geschlossen).
489
+ const target = canonicalizeFragment(rawHref);
490
+ if (target === null) continue;
491
+ let anc = useEl;
492
+ let from = '__root__';
493
+ let guard = 0;
494
+ while (anc) {
495
+ if (++guard > MAX_DOM_NEST_DEPTH) return REJECT('USE_GRAPH_NEST_DEPTH');
496
+ if (anc.id) {
497
+ from = anc.id;
498
+ break;
499
+ }
500
+ anc = anc.parentElement;
501
+ }
502
+ addEdge(from, target);
503
+ }
504
+ const { maxDepth } = computeMaxDepthIterative(edges);
505
+
506
+ // ── (2) totalExpansion über ALLE top-level svgs (iterativ, never-throw) ──
507
+ const svgRoots = collectSvgRoots(root);
508
+ const budget = { nodesLeft: nodeBudget };
509
+ let totalExpansion = 0;
510
+ let bailed = false;
511
+ for (const svgRoot of svgRoots) {
512
+ const res = computeExpansionIterative(
513
+ svgRoot,
514
+ byId,
515
+ expansionCap,
516
+ budget,
517
+ );
518
+ if (res.budgetExceeded) return REJECT('USE_GRAPH_NODE_BUDGET');
519
+ if (res.depthExceeded) return REJECT('USE_GRAPH_NEST_DEPTH');
520
+ totalExpansion += res.count;
521
+ if (res.bailed || totalExpansion > expansionCap) {
522
+ bailed = true;
523
+ totalExpansion = Math.max(totalExpansion, expansionCap + 1);
524
+ break;
525
+ }
526
+ }
527
+
528
+ const rejected =
529
+ maxDepth > maxDepthCap || totalExpansion > expansionCap || bailed;
530
+ return {
531
+ maxDepth,
532
+ totalExpansion,
533
+ cyclic: false,
534
+ rejected,
535
+ reason: rejected ? 'SECURITY_VIOLATION' : null,
536
+ };
537
+ } catch {
538
+ // never-throw-Vertrag: jeder unerwartete Fehler → kontrollierte Ablehnung.
539
+ return REJECT('USE_GRAPH_INTERNAL');
540
+ }
541
+ }