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.
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/package.json +65 -0
- package/src/adapters/emitter/prose.js +689 -0
- package/src/adapters/emitter/structured.js +649 -0
- package/src/adapters/renderer/playwright.js +7345 -0
- package/src/core/arbitrate.js +266 -0
- package/src/core/constraints/_schema.js +89 -0
- package/src/core/constraints/aligned.js +42 -0
- package/src/core/constraints/centered-in.js +29 -0
- package/src/core/constraints/color.js +63 -0
- package/src/core/constraints/distance.js +233 -0
- package/src/core/constraints/fill.js +22 -0
- package/src/core/constraints/inside.js +52 -0
- package/src/core/constraints/loader.js +65 -0
- package/src/core/constraints/no-overlap.js +50 -0
- package/src/core/constraints/positional.js +46 -0
- package/src/core/constraints/registry.js +98 -0
- package/src/core/constraints/same-size.js +35 -0
- package/src/core/diff.js +118 -0
- package/src/core/element_vocabulary.js +241 -0
- package/src/core/grid.js +240 -0
- package/src/core/honesty.js +214 -0
- package/src/core/sanitizer/auto_ids.js +104 -0
- package/src/core/tolerance.js +22 -0
- package/src/core/use_graph.js +541 -0
- package/src/interface/claims.js +439 -0
- package/src/interface/schema.js +626 -0
- package/src/interface/server.js +57 -0
- package/src/interface/tools.js +437 -0
- package/src/lib/bbox.js +17 -0
- package/src/lib/breaker.js +240 -0
- package/src/lib/geom.js +144 -0
- package/src/lib/palette.js +236 -0
- package/src/lib/transforms.js +111 -0
- package/src/pipeline.js +1983 -0
|
@@ -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
|
+
}
|