@patrickdk77/office-defuse 0.2.2 → 0.3.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/README.md +89 -11
- package/dist/biff.d.ts +11 -0
- package/dist/biff.js +15 -0
- package/dist/contained.d.ts +15 -5
- package/dist/contained.js +11 -7
- package/dist/context.d.ts +20 -6
- package/dist/context.js +28 -3
- package/dist/engine.js +7 -6
- package/dist/fields.js +1 -1
- package/dist/findings.js +9 -3
- package/dist/flat.js +4 -7
- package/dist/forms.d.ts +51 -0
- package/dist/forms.js +316 -0
- package/dist/macros.d.ts +26 -0
- package/dist/macros.js +160 -0
- package/dist/msg.js +1 -1
- package/dist/objects.js +76 -17
- package/dist/odf.js +9 -10
- package/dist/ole.d.ts +3 -3
- package/dist/ole.js +55 -5
- package/dist/ooxml.d.ts +26 -0
- package/dist/ooxml.js +163 -15
- package/dist/ppt-writer.d.ts +27 -0
- package/dist/ppt-writer.js +105 -0
- package/dist/ppt.d.ts +5 -1
- package/dist/ppt.js +179 -46
- package/dist/rtf.js +4 -2
- package/dist/score.d.ts +2 -0
- package/dist/score.js +12 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.js +3 -0
- package/dist/vba.d.ts +78 -0
- package/dist/vba.js +378 -0
- package/dist/xls-writer.d.ts +34 -0
- package/dist/xls-writer.js +250 -0
- package/dist/xls.d.ts +9 -1
- package/dist/xls.js +126 -43
- package/package.json +1 -1
package/dist/objects.js
CHANGED
|
@@ -11,7 +11,9 @@ const cfb_writer_1 = require("./cfb-writer");
|
|
|
11
11
|
const contained_1 = require("./contained");
|
|
12
12
|
const context_1 = require("./context");
|
|
13
13
|
const errors_1 = require("./errors");
|
|
14
|
+
const forms_1 = require("./forms");
|
|
14
15
|
const io_1 = require("./io");
|
|
16
|
+
const macros_1 = require("./macros");
|
|
15
17
|
const oleobj_1 = require("./oleobj");
|
|
16
18
|
const rewrite_cfb_1 = require("./rewrite-cfb");
|
|
17
19
|
const types_1 = require("./types");
|
|
@@ -44,35 +46,78 @@ const at = (prefix, e) => [prefix, (0, cfb_1.entryPath)(e)].filter(Boolean).join
|
|
|
44
46
|
*/
|
|
45
47
|
async function compoundRules(ctx, cfb, prefix, rootIsObject, plan, rootDecision) {
|
|
46
48
|
const projects = new Set();
|
|
49
|
+
/** Storages of embedded objects, whose contents the plugins that take the object decide. */
|
|
50
|
+
const objects = new Set();
|
|
51
|
+
const inObject = (e) => {
|
|
52
|
+
for (let p = e.parent; p; p = p.parent)
|
|
53
|
+
if (objects.has(p))
|
|
54
|
+
return true;
|
|
55
|
+
return false;
|
|
56
|
+
};
|
|
57
|
+
/** User form designers of a VBA project that stays, which go with the project rather than as objects. */
|
|
58
|
+
const designers = new Set();
|
|
59
|
+
const inDesigner = (e) => {
|
|
60
|
+
for (let p = e; p; p = p.parent)
|
|
61
|
+
if (designers.has(p))
|
|
62
|
+
return true;
|
|
63
|
+
return false;
|
|
64
|
+
};
|
|
47
65
|
const drop = (r, e) => {
|
|
48
66
|
if (r.action === 'strip' && e.kind !== 'root')
|
|
49
67
|
plan?.drop.add(e);
|
|
50
68
|
};
|
|
69
|
+
/** Objects a plugin answered for. Its answer is final, so nothing inside them gets the rules here. */
|
|
70
|
+
const answered = new Set();
|
|
71
|
+
const inAnswered = (e) => {
|
|
72
|
+
for (let p = e.parent; p; p = p.parent)
|
|
73
|
+
if (answered.has(p))
|
|
74
|
+
return true;
|
|
75
|
+
return false;
|
|
76
|
+
};
|
|
77
|
+
// A storage comes after the storage that holds it, so an object is decided before what it holds.
|
|
51
78
|
for (const e of cfb.entries) {
|
|
52
|
-
if (e.kind === 'stream')
|
|
79
|
+
if (e.kind === 'stream' || inDesigner(e) || inAnswered(e))
|
|
53
80
|
continue;
|
|
54
81
|
const child = (name) => e.children.find(c => upper(c) === name);
|
|
82
|
+
const names = new Set(e.children.map(upper));
|
|
83
|
+
const parent = e.parent ? upper(e.parent) : '';
|
|
84
|
+
const isObject = e.kind === 'root'
|
|
85
|
+
? rootIsObject
|
|
86
|
+
: [...names].some(n => OBJECT_STREAMS.has(n) || CONTROL_STREAMS.has(n)) ||
|
|
87
|
+
(parent === 'OBJECTPOOL' && e.parent?.parent?.kind === 'root') ||
|
|
88
|
+
(e.parent?.kind === 'root' && /^MBD[0-9A-F]{8}$/i.test(e.name));
|
|
89
|
+
if (isObject) {
|
|
90
|
+
objects.add(e);
|
|
91
|
+
const decided = await objectRules(ctx, cfb, e, at(prefix, e), plan, e.kind === 'root' ? rootDecision : undefined);
|
|
92
|
+
for (const r of decided.found)
|
|
93
|
+
drop(r, e);
|
|
94
|
+
if (decided.answered) {
|
|
95
|
+
answered.add(e);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
55
99
|
// A VBA project: a VBA storage with a dir stream, which names the modules (MS-OVBA 2.2). The storage that holds
|
|
56
|
-
// the project goes, or the VBA storage when that is the root.
|
|
100
|
+
// the project goes, or the VBA storage when that is the root. With script plugins, the project stays when they
|
|
101
|
+
// pass every module and the rewrite can take out its compiled code: in the file the rewrite writes, and outside any
|
|
102
|
+
// embedded object, whose plugins decide what it holds.
|
|
57
103
|
const vba = child('VBA');
|
|
58
104
|
if (vba?.kind === 'storage' && vba.children.some(c => c.kind === 'stream' && upper(c) === 'DIR')) {
|
|
59
105
|
projects.add(e);
|
|
60
|
-
|
|
106
|
+
const decision = plan && !objects.has(e) && !inObject(e) ? await (0, macros_1.decideVba)(ctx, cfb, vba, at(prefix, vba), plan) : undefined;
|
|
107
|
+
const data = { storage: at(prefix, vba), ...(decision?.reason ? { reason: decision.reason } : {}) };
|
|
108
|
+
if (decision?.keep)
|
|
109
|
+
for (const d of decision.designers ?? [])
|
|
110
|
+
designers.add(d);
|
|
111
|
+
// The plugins' answers are final: the report holds them, and nothing of office-defuse's own about the project.
|
|
112
|
+
if (decision?.answered)
|
|
113
|
+
drop({ action: decision.keep ? 'info' : 'strip' }, e.kind === 'root' ? vba : e);
|
|
114
|
+
else
|
|
115
|
+
drop(ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, at(prefix, vba), data), e.kind === 'root' ? vba : e);
|
|
61
116
|
}
|
|
62
117
|
if (e.kind === 'storage' && (upper(e) === '_VBA_PROJECT_CUR' || upper(e) === 'MACROS') && e.children.length && !projects.has(e)) {
|
|
63
118
|
// Excel's and Word's project storages, without the dir stream a whole project has.
|
|
64
119
|
drop(ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, at(prefix, e), { storage: at(prefix, e) }), e);
|
|
65
120
|
}
|
|
66
|
-
const names = new Set(e.children.map(upper));
|
|
67
|
-
const parent = e.parent ? upper(e.parent) : '';
|
|
68
|
-
const isObject = e.kind === 'root'
|
|
69
|
-
? rootIsObject
|
|
70
|
-
: [...names].some(n => OBJECT_STREAMS.has(n) || CONTROL_STREAMS.has(n)) ||
|
|
71
|
-
(parent === 'OBJECTPOOL' && e.parent?.parent?.kind === 'root') ||
|
|
72
|
-
(e.parent?.kind === 'root' && /^MBD[0-9A-F]{8}$/i.test(e.name));
|
|
73
|
-
if (isObject)
|
|
74
|
-
for (const r of await objectRules(ctx, cfb, e, at(prefix, e), plan, e.kind === 'root' ? rootDecision : undefined))
|
|
75
|
-
drop(r, e);
|
|
76
121
|
}
|
|
77
122
|
}
|
|
78
123
|
/** Reads an OLE object's storage: its class, its CompObj and OLE streams, and Packager's native data. */
|
|
@@ -159,6 +204,7 @@ async function objectFile(ctx, cfb, e, d, location, plan) {
|
|
|
159
204
|
* One OLE object's storage: a control, a linked object, or an embedded one, with what it holds. Returns its findings,
|
|
160
205
|
* and a strip when a plugin removed the object. In the file the rewrite writes, `plan` is set and an embedded object
|
|
161
206
|
* goes to the plugins first. `given` is the verdict on an object whose container showed it to the plugins already.
|
|
207
|
+
* `answered` says a plugin answered for the object, so nothing else here looks at it or at what it holds.
|
|
162
208
|
*/
|
|
163
209
|
async function objectRules(ctx, cfb, e, location, plan, given) {
|
|
164
210
|
const d = await describeObject(cfb, e, location);
|
|
@@ -169,8 +215,19 @@ async function objectRules(ctx, cfb, e, location, plan, given) {
|
|
|
169
215
|
data.progId = (0, context_1.clip)(d.progId);
|
|
170
216
|
if (d.userType)
|
|
171
217
|
data.className = (0, context_1.clip)(d.userType);
|
|
172
|
-
if (d.control)
|
|
173
|
-
|
|
218
|
+
if (d.control) {
|
|
219
|
+
if (d.controlName)
|
|
220
|
+
data.name = (0, context_1.clip)(d.controlName);
|
|
221
|
+
// A Forms 2.0 control stays when its storage and properties are as Office writes them. Its picture for printing,
|
|
222
|
+
// a metafile beside them, goes on its own.
|
|
223
|
+
const problem = await (0, forms_1.controlStorageProblem)(cfb, e, forms_1.WORD_CONTROL_STREAMS);
|
|
224
|
+
if (problem)
|
|
225
|
+
return { found: [ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, location, { ...data, reason: (0, context_1.clip)(problem) })], answered: false };
|
|
226
|
+
const print = e.children.find(c => c.kind === 'stream' && c.name === '\x03PRINT');
|
|
227
|
+
if (print && ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ControlPicture, `${location}/${(0, cfb_1.showName)(print.name)}`, { storage: location, bytes: print.size }).action === 'strip')
|
|
228
|
+
plan?.drop.add(print);
|
|
229
|
+
return { found: [ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.FormsControl, location, data)], answered: false };
|
|
230
|
+
}
|
|
174
231
|
const found = [];
|
|
175
232
|
if (d.pkg) {
|
|
176
233
|
Object.assign(data, packageData(d.pkg));
|
|
@@ -181,13 +238,15 @@ async function objectRules(ctx, cfb, e, location, plan, given) {
|
|
|
181
238
|
found.push(linkedObject(ctx, d, location, { storage: location, ...(d.clsid ? { clsid: d.clsid } : {}) }));
|
|
182
239
|
else {
|
|
183
240
|
const decision = given ?? (plan ? await objectFile(ctx, cfb, e, d, location, plan) : undefined);
|
|
184
|
-
|
|
241
|
+
if (decision?.answered)
|
|
242
|
+
return { found: [...found, { action: decision.keep ? 'info' : 'strip' }], answered: true };
|
|
243
|
+
found.push(ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleObject, location, data));
|
|
185
244
|
if (decision && !decision.keep)
|
|
186
245
|
found.push({ action: 'strip' });
|
|
187
246
|
}
|
|
188
247
|
if (d.equation)
|
|
189
248
|
found.push(ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Equation, location, data));
|
|
190
|
-
return found;
|
|
249
|
+
return { found, answered: false };
|
|
191
250
|
}
|
|
192
251
|
/**
|
|
193
252
|
* Reports a linked object. A URL moniker's target loads from outside the file whatever its text looks like: a
|
package/dist/odf.js
CHANGED
|
@@ -376,14 +376,14 @@ async function inspectOdf(ctx) {
|
|
|
376
376
|
const key = (0, zip_1.nameKey)(e.name);
|
|
377
377
|
const decision = await (0, contained_1.decideContained)(ctx, { name: e.name, location: e.name, chunks: () => zip.read(e) });
|
|
378
378
|
if (decision) {
|
|
379
|
-
decided.set(key, decision
|
|
379
|
+
decided.set(key, decision);
|
|
380
380
|
if (!decision.keep)
|
|
381
381
|
plan.drop.add(key);
|
|
382
382
|
else if (decision.replacement)
|
|
383
383
|
plan.replace.set(key, decision.replacement);
|
|
384
384
|
}
|
|
385
385
|
if (!oleRefs.get(key)) {
|
|
386
|
-
const r =
|
|
386
|
+
const r = (0, contained_1.ownFinding)(ctx, decision, types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleFile, e.name, { part: e.name });
|
|
387
387
|
if (!decision && r.action === 'strip')
|
|
388
388
|
plan.drop.add(key);
|
|
389
389
|
}
|
|
@@ -398,15 +398,14 @@ async function inspectOdf(ctx) {
|
|
|
398
398
|
}
|
|
399
399
|
const files = new Set(zip.entries.filter(e => !e.directory).map(e => (0, zip_1.nameKey)(e.name)));
|
|
400
400
|
for (const o of plan.objects) {
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
401
|
+
const decision = decided.get(o.target);
|
|
402
|
+
// A plugin's answer on the object's file is final, so the object's own finding is taken back.
|
|
403
|
+
if (decision?.answered)
|
|
404
|
+
ctx.retract(o.finding);
|
|
405
|
+
const kept = decision?.keep;
|
|
406
|
+
// A plugin kept the object's file, so the object stays.
|
|
407
|
+
if (kept === true)
|
|
408
408
|
continue;
|
|
409
|
-
}
|
|
410
409
|
if (kept === false || o.strips) {
|
|
411
410
|
plan.element(o.part, o.index, { remove: true });
|
|
412
411
|
if (files.has(o.target))
|
package/dist/ole.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type
|
|
1
|
+
import { type CompoundFile } from './cfb';
|
|
2
|
+
import { type RunContext } from './context';
|
|
3
3
|
import type { Format } from './formats';
|
|
4
4
|
import { ZipArchive } from './zip';
|
|
5
5
|
/** The families a compound file can hold, by the stream or storage that marks each. */
|
|
6
|
-
export type Family = 'word' | 'excel' | 'powerpoint' | 'visio' | 'outlook' | 'publisher' | 'project' | 'encrypted';
|
|
6
|
+
export type Family = 'word' | 'excel' | 'powerpoint' | 'powerpoint-addin' | 'visio' | 'outlook' | 'publisher' | 'project' | 'encrypted';
|
|
7
7
|
/** What the root of a compound file holds, read by the names of its children, ignoring case as MS-CFB does. */
|
|
8
8
|
export declare function families(cfb: CompoundFile): Family[];
|
|
9
9
|
/**
|
package/dist/ole.js
CHANGED
|
@@ -5,7 +5,10 @@ exports.mainFamily = mainFamily;
|
|
|
5
5
|
exports.compoundFormat = compoundFormat;
|
|
6
6
|
exports.openEncryptedPackage = openEncryptedPackage;
|
|
7
7
|
exports.inspectCompound = inspectCompound;
|
|
8
|
+
const cfb_1 = require("./cfb");
|
|
9
|
+
const context_1 = require("./context");
|
|
8
10
|
const errors_1 = require("./errors");
|
|
11
|
+
const forms_1 = require("./forms");
|
|
9
12
|
const msg_1 = require("./msg");
|
|
10
13
|
const objects_1 = require("./objects");
|
|
11
14
|
const offcrypto_1 = require("./offcrypto");
|
|
@@ -20,6 +23,11 @@ const zip_1 = require("./zip");
|
|
|
20
23
|
const f = (format, mimeType) => ({ format, mimeType });
|
|
21
24
|
const upper = (e) => e.name.toUpperCase();
|
|
22
25
|
const PUBLISHER_CLSIDS = new Set(['00021200-0000-0000-00C0-000000000046', '00021201-0000-0000-00C0-000000000046']);
|
|
26
|
+
/**
|
|
27
|
+
* The class id PowerPoint 97 to 2003 writes at the root of an add-in, a .ppa, whose root is its VBA project. An add-in
|
|
28
|
+
* whose project was removed keeps it, and is still an add-in.
|
|
29
|
+
*/
|
|
30
|
+
const PPA_CLSID = '817246F0-720A-11CF-8718-00AA0060263B';
|
|
23
31
|
/** What the root of a compound file holds, read by the names of its children, ignoring case as MS-CFB does. */
|
|
24
32
|
function families(cfb) {
|
|
25
33
|
const names = new Map(cfb.root.children.map(c => [upper(c), c]));
|
|
@@ -33,6 +41,8 @@ function families(cfb) {
|
|
|
33
41
|
out.push('excel');
|
|
34
42
|
if (stream('POWERPOINT DOCUMENT'))
|
|
35
43
|
out.push('powerpoint');
|
|
44
|
+
if (cfb.root.clsid === PPA_CLSID)
|
|
45
|
+
out.push('powerpoint-addin');
|
|
36
46
|
if (stream('VISIODOCUMENT'))
|
|
37
47
|
out.push('visio');
|
|
38
48
|
if (stream('__PROPERTIES_VERSION1.0'))
|
|
@@ -91,6 +101,8 @@ async function compoundFormat(cfb, family, deadline) {
|
|
|
91
101
|
}
|
|
92
102
|
case 'powerpoint':
|
|
93
103
|
return f('ppt', 'application/vnd.ms-powerpoint');
|
|
104
|
+
case 'powerpoint-addin':
|
|
105
|
+
return f('ppa', 'application/vnd.ms-powerpoint');
|
|
94
106
|
case 'visio':
|
|
95
107
|
return f('vsd', 'application/vnd.visio');
|
|
96
108
|
case 'outlook':
|
|
@@ -134,21 +146,29 @@ async function inspectCompound(ctx, cfb) {
|
|
|
134
146
|
if (word)
|
|
135
147
|
await (0, word_1.wordRules)(ctx, cfb, word, family === 'encrypted');
|
|
136
148
|
if (family === 'excel') {
|
|
149
|
+
const found = [];
|
|
137
150
|
for (const name of ['Workbook', 'Book']) {
|
|
138
151
|
const s = stream(name);
|
|
139
152
|
if (s)
|
|
140
|
-
await (0, xls_1.excelRules)(ctx, cfb, s, offcrypto_1.unlockWorkbook);
|
|
153
|
+
await (0, xls_1.excelRules)(ctx, cfb, s, offcrypto_1.unlockWorkbook, plan, found);
|
|
141
154
|
}
|
|
142
|
-
// Excel keeps the data of a workbook's ActiveX controls in one Ctls stream.
|
|
155
|
+
// Excel keeps the data of a workbook's ActiveX controls in one Ctls stream. It stays when every control in it is a
|
|
156
|
+
// Forms 2.0 control as Office persists one, and the Obj records account for all of it.
|
|
143
157
|
const controls = stream('Ctls');
|
|
144
|
-
if (controls && controls.size > 0
|
|
145
|
-
|
|
158
|
+
if (controls && controls.size > 0) {
|
|
159
|
+
const problem = await ctlsProblem(cfb, controls, found);
|
|
160
|
+
const reported = problem
|
|
161
|
+
? [ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, controls.name, { stream: controls.name, reason: (0, context_1.clip)(problem) })]
|
|
162
|
+
: found.map(c => ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.FormsControl, `${controls.name}, offset ${c.position}`, { stream: controls.name, sheet: (0, context_1.clip)(c.sheet), progId: (0, context_1.clip)(c.progId) }));
|
|
163
|
+
if (reported.some(r => r.action === 'strip'))
|
|
164
|
+
plan.drop.add(controls);
|
|
165
|
+
}
|
|
146
166
|
}
|
|
147
167
|
if (family === 'outlook')
|
|
148
168
|
await (0, msg_1.messageRules)(ctx, cfb, cfb.root, 0, plan);
|
|
149
169
|
const ppt = stream('PowerPoint Document');
|
|
150
170
|
if (family === 'powerpoint' && ppt)
|
|
151
|
-
await (0, ppt_1.powerPointRules)(ctx, cfb, ppt);
|
|
171
|
+
await (0, ppt_1.powerPointRules)(ctx, cfb, ppt, plan);
|
|
152
172
|
const visio = stream('VisioDocument');
|
|
153
173
|
if (family === 'visio' && visio)
|
|
154
174
|
await (0, vsd_1.visioRules)(ctx, cfb, visio);
|
|
@@ -165,3 +185,33 @@ async function inspectCompound(ctx, cfb) {
|
|
|
165
185
|
ctx.run.output = { kind: 'zip', zip, plan: zctx.plan, format: ctx.format };
|
|
166
186
|
}
|
|
167
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Why the Ctls stream may hold more than Forms 2.0 controls as Office persists them, or undefined when it does not.
|
|
190
|
+
* Each control is its class id and then its properties, at the place its Obj record gives, and the places cover the
|
|
191
|
+
* stream with nothing between or after them.
|
|
192
|
+
*/
|
|
193
|
+
async function ctlsProblem(cfb, ctls, controls) {
|
|
194
|
+
if (!controls.length)
|
|
195
|
+
return 'no Obj record names a control in it';
|
|
196
|
+
if (ctls.size > 16 * 1024 * 1024)
|
|
197
|
+
return 'it is larger than Forms 2.0 controls without pictures';
|
|
198
|
+
const bytes = await cfb.bytes(ctls);
|
|
199
|
+
let covered = 0;
|
|
200
|
+
for (const c of [...controls].sort((a, b) => a.position - b.position)) {
|
|
201
|
+
if (c.position !== covered)
|
|
202
|
+
return `bytes at offset ${covered} that no control's place covers`;
|
|
203
|
+
if (c.position + c.size > bytes.length || c.size < 16)
|
|
204
|
+
return `a control's place runs past the stream`;
|
|
205
|
+
const classId = forms_1.FORMS_PROGIDS.get(c.progId.trim().toLowerCase());
|
|
206
|
+
if (!classId)
|
|
207
|
+
return `a control of class ${c.progId || 'not named'}`;
|
|
208
|
+
const data = bytes.subarray(c.position, c.position + c.size);
|
|
209
|
+
if ((0, cfb_1.guid)(data, 0) !== classId)
|
|
210
|
+
return `a control whose data names another class than ${c.progId}`;
|
|
211
|
+
const problem = (0, forms_1.controlProblem)(classId, data.subarray(16));
|
|
212
|
+
if (problem)
|
|
213
|
+
return problem;
|
|
214
|
+
covered = c.position + c.size;
|
|
215
|
+
}
|
|
216
|
+
return covered === bytes.length ? undefined : `${bytes.length - covered} bytes after the last control`;
|
|
217
|
+
}
|
package/dist/ooxml.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ContainedDecision } from './contained';
|
|
1
2
|
import { type ZipContext } from './context';
|
|
2
3
|
import { type Format } from './formats';
|
|
3
4
|
import { type XmlElement } from './xml';
|
|
@@ -52,6 +53,30 @@ export interface Part {
|
|
|
52
53
|
/** The parts its own relationships reach. */
|
|
53
54
|
targets: Set<Part>;
|
|
54
55
|
ole: boolean;
|
|
56
|
+
/** For a VBA project part read before the XML parts, what became of it. */
|
|
57
|
+
vba?: ContainedDecision;
|
|
58
|
+
/** For an activeX part, the control its ax:ocx element describes. */
|
|
59
|
+
activeX?: ActiveXInfo;
|
|
60
|
+
/** For an activeX part, what the check of its control found. */
|
|
61
|
+
forms?: FormsVerdict;
|
|
62
|
+
/** A binary part that holds the data of a Forms 2.0 control that stays. */
|
|
63
|
+
formsData?: boolean;
|
|
64
|
+
}
|
|
65
|
+
/** What an activeX part's ax:ocx element says: the class, how the control persists itself, and a property bag. */
|
|
66
|
+
export interface ActiveXInfo {
|
|
67
|
+
classId: string;
|
|
68
|
+
persistence?: string;
|
|
69
|
+
props: Array<{
|
|
70
|
+
name: string;
|
|
71
|
+
value: string;
|
|
72
|
+
}>;
|
|
73
|
+
/** An element below ax:ocxPr, such as a font or picture, or one ax:ocx does not hold. */
|
|
74
|
+
nested: boolean;
|
|
75
|
+
}
|
|
76
|
+
/** An activeX part's control as checked: its class, and why it may not be a Forms 2.0 control as Office writes one. */
|
|
77
|
+
interface FormsVerdict {
|
|
78
|
+
classId?: string;
|
|
79
|
+
problem?: string;
|
|
55
80
|
}
|
|
56
81
|
/** What the rewrite needs of an OOXML package: its parts, which part each relationships part belongs to, and the roots. */
|
|
57
82
|
export interface OoxmlGraph {
|
|
@@ -112,3 +137,4 @@ export declare function nameOoxml(ctx: ZipContext, contentTypes: ZipEntry): Prom
|
|
|
112
137
|
* OPC package unpacked into a ZIP archive, and the package's own format is returned.
|
|
113
138
|
*/
|
|
114
139
|
export declare function inspectOoxml(ctx: ZipContext, contentTypes: ZipEntry, outer?: Format): Promise<Format>;
|
|
140
|
+
export {};
|
package/dist/ooxml.js
CHANGED
|
@@ -10,14 +10,19 @@ exports.readMain = readMain;
|
|
|
10
10
|
exports.nameOoxml = nameOoxml;
|
|
11
11
|
exports.inspectOoxml = inspectOoxml;
|
|
12
12
|
const biff_1 = require("./biff");
|
|
13
|
+
const cfb_1 = require("./cfb");
|
|
13
14
|
const contained_1 = require("./contained");
|
|
14
15
|
const context_1 = require("./context");
|
|
15
16
|
const errors_1 = require("./errors");
|
|
16
17
|
const fields_1 = require("./fields");
|
|
17
18
|
const formats_1 = require("./formats");
|
|
19
|
+
const forms_1 = require("./forms");
|
|
18
20
|
const formulas_1 = require("./formulas");
|
|
21
|
+
const io_1 = require("./io");
|
|
19
22
|
const links_1 = require("./links");
|
|
23
|
+
const objects_1 = require("./objects");
|
|
20
24
|
const plan_1 = require("./plan");
|
|
25
|
+
const rewrite_cfb_1 = require("./rewrite-cfb");
|
|
21
26
|
const types_1 = require("./types");
|
|
22
27
|
const xlsb_1 = require("./xlsb");
|
|
23
28
|
const xml_1 = require("./xml");
|
|
@@ -98,6 +103,7 @@ exports.R_NS = new Set(['http://schemas.openxmlformats.org/officedocument/2006/r
|
|
|
98
103
|
const CUSTOM_UI_NS = new Set(['http://schemas.microsoft.com/office/2006/01/customui', 'http://schemas.microsoft.com/office/2009/07/customui']);
|
|
99
104
|
const VML_EXCEL_NS = 'urn:schemas-microsoft-com:office:excel';
|
|
100
105
|
const VISIO_NS = 'http://schemas.microsoft.com/office/visio/2012/main';
|
|
106
|
+
const ACTIVEX_NS = 'http://schemas.microsoft.com/office/2006/activex';
|
|
101
107
|
const EXTENDED_NS = new Set(['http://schemas.openxmlformats.org/officedocument/2006/extended-properties', 'http://purl.oclc.org/ooxml/officedocument/extendedproperties']);
|
|
102
108
|
/**
|
|
103
109
|
* The attributes of a SpreadsheetML data connection that name where it loads from (ECMA-376 18.13): an ODC file or an
|
|
@@ -310,6 +316,8 @@ class PartRules {
|
|
|
310
316
|
hyperlinkBase;
|
|
311
317
|
/** The parts this XPS part names in its markup, by key, which count as reached from it. */
|
|
312
318
|
refs = new Set();
|
|
319
|
+
/** The control an activeX part describes in its ax:ocx element. */
|
|
320
|
+
activeX;
|
|
313
321
|
/**
|
|
314
322
|
* The labels of the hyperlinks the part names by relationship Id: their text, tooltips and display text. The
|
|
315
323
|
* relationships part comes after the part, so the targets are compared with these as it is read.
|
|
@@ -358,14 +366,27 @@ class PartRules {
|
|
|
358
366
|
}
|
|
359
367
|
start(el) {
|
|
360
368
|
const ns = this.lowerNs(el);
|
|
369
|
+
// Below ax:ocx only ax:ocxPr may stand, and nothing below it: anything else marks the bag as more than plain values.
|
|
370
|
+
if (this.activeX && (el.depth > 2 || (el.depth === 2 && !(ns === ACTIVEX_NS && el.local === 'ocxPr'))))
|
|
371
|
+
this.activeX.nested = true;
|
|
372
|
+
if (ns === ACTIVEX_NS) {
|
|
373
|
+
const value = (local) => el.attributes.find(a => a.ns.toLowerCase() === ACTIVEX_NS && a.local === local)?.value;
|
|
374
|
+
if (el.depth === 1 && el.local === 'ocx')
|
|
375
|
+
this.activeX = { classId: value('classid') ?? '', persistence: value('persistence'), props: [], nested: false };
|
|
376
|
+
else if (this.activeX && el.depth === 2 && el.local === 'ocxPr')
|
|
377
|
+
this.activeX.props.push({ name: value('name') ?? '', value: value('value') ?? '' });
|
|
378
|
+
else if (this.activeX)
|
|
379
|
+
this.activeX.nested = true;
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
361
382
|
if (CUSTOM_UI_NS.has(ns)) {
|
|
362
383
|
if (el.depth === 1)
|
|
363
384
|
this.ctx.report(types_1.OfficeCategory.Ui, types_1.OfficeDetail.Ribbon, this.part);
|
|
364
385
|
for (const a of el.attributes) {
|
|
365
386
|
if (a.ns !== '' || !a.value.trim() || !/^(on[A-Z]|get[A-Z]|loadImage$)/.test(a.local))
|
|
366
387
|
continue;
|
|
367
|
-
// The callback goes and the control stays, doing nothing.
|
|
368
|
-
if (this.ctx.
|
|
388
|
+
// The callback goes and the control stays, doing nothing, unless it calls a macro of a project that stays.
|
|
389
|
+
if (this.ctx.macroTrigger(types_1.OfficeCategory.Ui, types_1.OfficeDetail.Callback, this.part, { callback: `${a.local}=${(0, context_1.clip)(a.value)}` }, a.value).action === 'strip') {
|
|
369
390
|
this.ctx.plan.element(this.part, el.index, { strip: new Set([(0, plan_1.attributeKey)('', a.local)]) });
|
|
370
391
|
}
|
|
371
392
|
}
|
|
@@ -398,7 +419,7 @@ class PartRules {
|
|
|
398
419
|
else if (XDR_NS.has(ns)) {
|
|
399
420
|
const macro = (0, xml_1.attr)(el, '', 'macro');
|
|
400
421
|
// The shape stays, and clicking it runs nothing.
|
|
401
|
-
if (macro?.trim() && this.ctx.
|
|
422
|
+
if (macro?.trim() && this.ctx.macroTrigger(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, { macro: (0, context_1.clip)(macro) }, macro).action === 'strip') {
|
|
402
423
|
this.ctx.plan.element(this.part, el.index, { strip: new Set([(0, plan_1.attributeKey)('', 'macro')]) });
|
|
403
424
|
}
|
|
404
425
|
}
|
|
@@ -455,8 +476,9 @@ class PartRules {
|
|
|
455
476
|
this.formula = undefined;
|
|
456
477
|
}
|
|
457
478
|
if (ns === VML_EXCEL_NS && el.local === 'FmlaMacro' && this.macroText) {
|
|
479
|
+
const macro = this.macroText.shown.text.trim();
|
|
458
480
|
if (this.macroText.shown.content)
|
|
459
|
-
this.remove(this.ctx.
|
|
481
|
+
this.remove(this.ctx.macroTrigger(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, { macro: (0, context_1.clip)(macro) }, macro), this.macroText);
|
|
460
482
|
this.macroText = undefined;
|
|
461
483
|
}
|
|
462
484
|
if (this.hyperlinkBase && EXTENDED_NS.has(ns) && el.local === 'HyperlinkBase') {
|
|
@@ -495,8 +517,10 @@ class PartRules {
|
|
|
495
517
|
const data = { trigger: el.local, action: (0, context_1.clip)(written), ...(id === undefined ? {} : { relationship: (0, context_1.clip)(id) }) };
|
|
496
518
|
if (action.startsWith('ppaction://program'))
|
|
497
519
|
this.remove(this.ctx.report(types_1.OfficeCategory.Action, types_1.OfficeDetail.RunProgram, this.part, data), el);
|
|
498
|
-
else if (action.startsWith('ppaction://macro'))
|
|
499
|
-
|
|
520
|
+
else if (action.startsWith('ppaction://macro')) {
|
|
521
|
+
const name = /[?&]name=([^&]*)/i.exec(written)?.[1] ?? '';
|
|
522
|
+
this.remove(this.ctx.macroTrigger(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, data, safeDecode(name)), el);
|
|
523
|
+
}
|
|
500
524
|
else if (action.startsWith('ppaction://ole'))
|
|
501
525
|
this.remove(this.ctx.report(types_1.OfficeCategory.Action, types_1.OfficeDetail.OleVerb, this.part, data), el);
|
|
502
526
|
}
|
|
@@ -641,6 +665,12 @@ async function inspectOoxml(ctx, contentTypes, outer) {
|
|
|
641
665
|
};
|
|
642
666
|
const { format, strict, xps } = await readMain(ctx, typed, (rels, r) => relate(rels, undefined, r));
|
|
643
667
|
ctx.format = outer ?? format;
|
|
668
|
+
// A VBA project goes to the script plugins before the XML parts are read, whose buttons and callbacks keep running
|
|
669
|
+
// its macros only when it stays.
|
|
670
|
+
if (ctx.run.options.scriptPlugins !== undefined)
|
|
671
|
+
for (const p of parts.values())
|
|
672
|
+
if (exports.TYPE.vba.has(p.contentType ?? ''))
|
|
673
|
+
p.vba = await vbaPart(ctx, p.entry, p.entry.name);
|
|
644
674
|
// OPC has no folders. A directory entry is still inflated once, so its bytes are checked like any other.
|
|
645
675
|
for (const entry of ctx.zip.entries)
|
|
646
676
|
if (entry.directory)
|
|
@@ -661,6 +691,8 @@ async function inspectOoxml(ctx, contentTypes, outer) {
|
|
|
661
691
|
await ctx.parse(p.entry, rules, ct === exports.TYPE.vml);
|
|
662
692
|
if (rules.labels.size)
|
|
663
693
|
labelsOf.set(key, rules.labels);
|
|
694
|
+
if (rules.activeX)
|
|
695
|
+
p.activeX = rules.activeX;
|
|
664
696
|
for (const k of rules.refs) {
|
|
665
697
|
const t = parts.get(k);
|
|
666
698
|
if (t && t !== p)
|
|
@@ -686,6 +718,10 @@ async function inspectOoxml(ctx, contentTypes, outer) {
|
|
|
686
718
|
for (const [source, rels] of relsOf)
|
|
687
719
|
if (source !== '' && !parts.has(source))
|
|
688
720
|
await readRelationships(ctx, rels, r => relate(rels, undefined, r));
|
|
721
|
+
// Controls are checked before any part's own rules, since a Forms 2.0 control that stays keeps its binary part.
|
|
722
|
+
for (const p of parts.values())
|
|
723
|
+
if (exports.TYPE.control.has(p.contentType ?? '') || (p.reachedAs & AS.control) !== 0)
|
|
724
|
+
p.forms = await formsControl(ctx, p);
|
|
689
725
|
for (const p of parts.values())
|
|
690
726
|
await partRules(ctx, p);
|
|
691
727
|
unreferenced(ctx, parts, relsOf, roots);
|
|
@@ -711,28 +747,140 @@ async function partRules(ctx, p) {
|
|
|
711
747
|
// Besides OLE parts and packages, every part that is a whole file in a format of its own, such as an image, goes
|
|
712
748
|
// to the plugins. Such a part has no rule of its own, so one no plugin takes stays.
|
|
713
749
|
const wholeFile = !(0, exports.isXmlType)(p.contentType) && !formatBinary(ct);
|
|
750
|
+
// The data of a Forms 2.0 control that stays was read with the control, and stays with it.
|
|
751
|
+
if (p.formsData)
|
|
752
|
+
return;
|
|
714
753
|
const contained = vba || ole || pkg || controlBinary || p.ole || wholeFile;
|
|
715
|
-
|
|
716
|
-
const
|
|
754
|
+
// With script plugins, a VBA project part is read here, so its modules go to them, rather than to the file plugins.
|
|
755
|
+
const vbaDecision = p.vba ?? (vba && ctx.run.options.scriptPlugins !== undefined ? await vbaPart(ctx, p.entry, loc) : undefined);
|
|
756
|
+
const decision = vbaDecision ?? (contained ? await (0, contained_1.decideContained)(ctx, { name: loc, declaredType: ct || undefined, location: loc, chunks: () => ctx.zip.read(p.entry) }) : undefined);
|
|
717
757
|
const found = [];
|
|
718
|
-
|
|
719
|
-
|
|
758
|
+
// A plugin's answer is final, so a file it answered for gets none of office-defuse's own findings.
|
|
759
|
+
if (vba && !vbaDecision)
|
|
760
|
+
found.push((0, contained_1.ownFinding)(ctx, decision, types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, loc, data));
|
|
720
761
|
// A macro sheet becomes an empty worksheet, so the indexes of the sheets after it stay right.
|
|
721
762
|
if ((exports.TYPE.macrosheet.has(ct) || as(AS.macrosheet)) && ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.XlmMacrosheet, loc, data).action === 'strip')
|
|
722
763
|
ctx.plan.sheets.add(key);
|
|
723
764
|
if (ole)
|
|
724
|
-
found.push(
|
|
765
|
+
found.push((0, contained_1.ownFinding)(ctx, decision, types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleObject, loc, data));
|
|
725
766
|
if (pkg)
|
|
726
|
-
found.push(
|
|
727
|
-
if (control
|
|
728
|
-
found.push(ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.
|
|
767
|
+
found.push((0, contained_1.ownFinding)(ctx, decision, types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Package, loc, data));
|
|
768
|
+
if (control && p.forms && !p.forms.problem)
|
|
769
|
+
found.push(ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.FormsControl, loc, { ...data, clsid: p.forms.classId ?? '' }));
|
|
770
|
+
else if (control || (controlBinary && !p.claimed)) {
|
|
771
|
+
const why = control && p.forms ? { ...(p.forms.classId ? { clsid: p.forms.classId } : {}), ...(p.forms.problem ? { reason: (0, context_1.clip)(p.forms.problem) } : {}) } : {};
|
|
772
|
+
found.push(control ? ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, loc, { ...data, ...why }) : (0, contained_1.ownFinding)(ctx, decision, types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, loc, { ...data, ...why }));
|
|
773
|
+
}
|
|
729
774
|
if (p.ole && !vba && !ole && !control && !controlBinary && !pkg)
|
|
730
|
-
found.push(
|
|
775
|
+
found.push((0, contained_1.ownFinding)(ctx, decision, types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleFile, loc, data));
|
|
731
776
|
if (decision ? !decision.keep : found.some(f => f.action === 'strip'))
|
|
732
777
|
ctx.plan.drop.add(key);
|
|
733
778
|
else if (decision?.replacement)
|
|
734
779
|
ctx.plan.replace.set(key, decision.replacement);
|
|
735
780
|
}
|
|
781
|
+
/** Bytes a Forms 2.0 control's binary part holds at most, pictures left out. */
|
|
782
|
+
const MAX_CONTROL_DATA = 65536;
|
|
783
|
+
/**
|
|
784
|
+
* Checks the control an activeX part describes as a Forms 2.0 control that Office persisted: by its class, and by
|
|
785
|
+
* its properties, read from the property bag in the part or from the binary part its relationship names. A control
|
|
786
|
+
* persisted as a stream starts with its class id; one persisted as a storage holds its CompObj and contents. A
|
|
787
|
+
* control that passes marks its binary part, which then stays with it.
|
|
788
|
+
*/
|
|
789
|
+
async function formsControl(ctx, p) {
|
|
790
|
+
const ax = p.activeX;
|
|
791
|
+
if (!ax)
|
|
792
|
+
return { problem: 'its part has no ax:ocx element' };
|
|
793
|
+
const classId = ax.classId.replace(/[{}]/g, '').trim().toUpperCase();
|
|
794
|
+
if (!forms_1.FORMS_CONTROLS.has(classId))
|
|
795
|
+
return { classId, problem: 'its class is not a Forms 2.0 control office-defuse reads' };
|
|
796
|
+
const binaries = [...p.targets].filter(t => t.claimed);
|
|
797
|
+
const persistence = ax.persistence ?? 'persistStreamInit';
|
|
798
|
+
if (persistence === 'persistPropertyBag') {
|
|
799
|
+
if (binaries.length)
|
|
800
|
+
return { classId, problem: 'a property bag with a binary part' };
|
|
801
|
+
return { classId, problem: (0, forms_1.bagProblem)(classId, ax.props, ax.nested) };
|
|
802
|
+
}
|
|
803
|
+
if (ax.props.length || ax.nested)
|
|
804
|
+
return { classId, problem: `properties in its part beside ${persistence}` };
|
|
805
|
+
if (binaries.length !== 1)
|
|
806
|
+
return { classId, problem: 'it has no binary part, or more than one' };
|
|
807
|
+
const bin = binaries[0];
|
|
808
|
+
if (bin.entry.size > MAX_CONTROL_DATA)
|
|
809
|
+
return { classId, problem: 'its binary part is larger than a Forms 2.0 control without pictures' };
|
|
810
|
+
const chunks = [];
|
|
811
|
+
for await (const c of ctx.zip.read(bin.entry))
|
|
812
|
+
chunks.push(c);
|
|
813
|
+
const bytes = Buffer.concat(chunks);
|
|
814
|
+
let problem;
|
|
815
|
+
if (persistence === 'persistStreamInit' || persistence === 'persistStream') {
|
|
816
|
+
problem = bytes.length < 16 || (0, cfb_1.guid)(bytes, 0) !== classId ? 'its binary part names another class' : (0, forms_1.controlProblem)(classId, bytes.subarray(16));
|
|
817
|
+
}
|
|
818
|
+
else if (persistence === 'persistStorage') {
|
|
819
|
+
let cfb;
|
|
820
|
+
try {
|
|
821
|
+
cfb = await cfb_1.CompoundFile.open((0, io_1.bufferSource)(bytes), ctx.settings, ctx.run.deadline);
|
|
822
|
+
}
|
|
823
|
+
catch (e) {
|
|
824
|
+
if (!(e instanceof errors_1.StopError) || e.category !== types_1.OfficeCategory.Container)
|
|
825
|
+
throw e;
|
|
826
|
+
problem = `its binary part is not a compound file: ${e.message}`;
|
|
827
|
+
}
|
|
828
|
+
if (cfb)
|
|
829
|
+
problem = await (0, forms_1.controlStorageProblem)(cfb, cfb.root, forms_1.OOXML_CONTROL_STREAMS, classId);
|
|
830
|
+
}
|
|
831
|
+
else
|
|
832
|
+
problem = `its persistence is ${persistence}`;
|
|
833
|
+
if (!problem)
|
|
834
|
+
bin.formsData = true;
|
|
835
|
+
return { classId, problem };
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* A VBA project part, read as the compound file it is: the rules every compound file gets, which show the project to
|
|
839
|
+
* the script plugins and the storages of its forms to the file plugins. The part stays, as the rewrite writes it, when
|
|
840
|
+
* the project does.
|
|
841
|
+
*/
|
|
842
|
+
async function vbaPart(ctx, entry, loc) {
|
|
843
|
+
const run = ctx.run;
|
|
844
|
+
const copy = new io_1.SpillSink(run.temp, run.threshold);
|
|
845
|
+
run.held.push(copy);
|
|
846
|
+
for await (const c of ctx.zip.read(entry))
|
|
847
|
+
await copy.write(c);
|
|
848
|
+
await copy.close();
|
|
849
|
+
const source = copy.source();
|
|
850
|
+
run.sources.push(source);
|
|
851
|
+
let cfb;
|
|
852
|
+
try {
|
|
853
|
+
cfb = await cfb_1.CompoundFile.open(source, ctx.settings, run.deadline);
|
|
854
|
+
}
|
|
855
|
+
catch (e) {
|
|
856
|
+
if (!(e instanceof errors_1.StopError) || e.category !== types_1.OfficeCategory.Container)
|
|
857
|
+
throw e;
|
|
858
|
+
ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, loc, { part: loc, reason: (0, context_1.clip)(`its compound file does not read: ${e.message}`) });
|
|
859
|
+
return { keep: false };
|
|
860
|
+
}
|
|
861
|
+
(0, objects_1.nestedNotes)(ctx, cfb, loc);
|
|
862
|
+
const plan = new plan_1.CompoundPlan();
|
|
863
|
+
await (0, objects_1.compoundRules)(ctx, cfb, loc, false, plan);
|
|
864
|
+
const vba = cfb.child(cfb.root, 'VBA');
|
|
865
|
+
if (!vba)
|
|
866
|
+
ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, loc, { part: loc, reason: 'it holds no VBA storage' });
|
|
867
|
+
if (!vba || plan.drop.has(vba))
|
|
868
|
+
return { keep: false };
|
|
869
|
+
const out = new io_1.SpillSink(run.temp, run.threshold);
|
|
870
|
+
run.held.push(out);
|
|
871
|
+
await (0, rewrite_cfb_1.writeCompound)(run, cfb, plan, out);
|
|
872
|
+
await out.close();
|
|
873
|
+
return { keep: true, replacement: out };
|
|
874
|
+
}
|
|
875
|
+
/** A percent-encoded value, or the value as written when it does not decode. */
|
|
876
|
+
function safeDecode(v) {
|
|
877
|
+
try {
|
|
878
|
+
return decodeURIComponent(v);
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
return v;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
736
884
|
/** Parts that no chain of internal relationships reaches from the package. */
|
|
737
885
|
function unreferenced(ctx, parts, relsOf, roots) {
|
|
738
886
|
const reached = new Set(roots);
|