@patrickdk77/office-defuse 0.2.1 → 0.2.4

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/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 && ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, controls.name, { stream: controls.name }).action === 'strip')
145
- plan.drop.add(controls);
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.report(types_1.OfficeCategory.Ui, types_1.OfficeDetail.Callback, this.part, { callback: `${a.local}=${(0, context_1.clip)(a.value)}` }).action === 'strip') {
388
+ // The callback goes and the control stays, doing nothing, unless it calls a macro of a project that stays.
389
+ if (this.ctx.report(types_1.OfficeCategory.Ui, types_1.OfficeDetail.Callback, this.part, { callback: `${a.local}=${(0, context_1.clip)(a.value)}` }, (0, context_1.ownMacro)(this.ctx.run, 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.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, { macro: (0, context_1.clip)(macro) }).action === 'strip') {
422
+ if (macro?.trim() && this.ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, { macro: (0, context_1.clip)(macro) }, (0, context_1.ownMacro)(this.ctx.run, 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.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, { macro: (0, context_1.clip)(this.macroText.shown.text.trim()) }), this.macroText);
481
+ this.remove(this.ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, { macro: (0, context_1.clip)(macro) }, (0, context_1.ownMacro)(this.ctx.run, 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
- this.remove(this.ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, data), el);
520
+ else if (action.startsWith('ppaction://macro')) {
521
+ const name = /[?&]name=([^&]*)/i.exec(written)?.[1] ?? '';
522
+ this.remove(this.ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, data, (0, context_1.ownMacro)(this.ctx.run, 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
  }
@@ -597,6 +621,8 @@ async function readMain(ctx, { parts, relsOf }, each) {
597
621
  async function nameOoxml(ctx, contentTypes) {
598
622
  return (await readMain(ctx, await readParts(ctx, contentTypes))).format;
599
623
  }
624
+ /** The content type of an add-in's presentation part, which PowerPoint writes empty. */
625
+ const PPAM_MAIN = 'application/vnd.ms-powerpoint.addin.macroenabled.main+xml';
600
626
  /**
601
627
  * Inspects an OOXML package. The package relationships come first, since they name the main part and so the format.
602
628
  * Then each part is read in archive order, followed by its relationships, which are reported as they are read and
@@ -639,6 +665,12 @@ async function inspectOoxml(ctx, contentTypes, outer) {
639
665
  };
640
666
  const { format, strict, xps } = await readMain(ctx, typed, (rels, r) => relate(rels, undefined, r));
641
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);
642
674
  // OPC has no folders. A directory entry is still inflated once, so its bytes are checked like any other.
643
675
  for (const entry of ctx.zip.entries)
644
676
  if (entry.directory)
@@ -650,11 +682,17 @@ async function inspectOoxml(ctx, contentTypes, outer) {
650
682
  plan.drop.add(key);
651
683
  p.ole = await ctx.drain(p.entry);
652
684
  }
685
+ else if (format.format === 'ppam' && p.entry.size === 0 && ct.toLowerCase() === PPAM_MAIN) {
686
+ // PowerPoint writes an add-in's presentation part empty, since an add-in has no slides.
687
+ await ctx.drain(p.entry);
688
+ }
653
689
  else if ((0, exports.isXmlType)(ct)) {
654
690
  const rules = new PartRules(ctx, p.entry.name);
655
691
  await ctx.parse(p.entry, rules, ct === exports.TYPE.vml);
656
692
  if (rules.labels.size)
657
693
  labelsOf.set(key, rules.labels);
694
+ if (rules.activeX)
695
+ p.activeX = rules.activeX;
658
696
  for (const k of rules.refs) {
659
697
  const t = parts.get(k);
660
698
  if (t && t !== p)
@@ -680,6 +718,10 @@ async function inspectOoxml(ctx, contentTypes, outer) {
680
718
  for (const [source, rels] of relsOf)
681
719
  if (source !== '' && !parts.has(source))
682
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);
683
725
  for (const p of parts.values())
684
726
  await partRules(ctx, p);
685
727
  unreferenced(ctx, parts, relsOf, roots);
@@ -705,11 +747,16 @@ async function partRules(ctx, p) {
705
747
  // Besides OLE parts and packages, every part that is a whole file in a format of its own, such as an image, goes
706
748
  // to the plugins. Such a part has no rule of its own, so one no plugin takes stays.
707
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;
708
753
  const contained = vba || ole || pkg || controlBinary || p.ole || wholeFile;
709
- const decision = contained ? await (0, contained_1.decideContained)(ctx, { name: loc, declaredType: ct || undefined, location: loc, chunks: () => ctx.zip.read(p.entry) }) : undefined;
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);
710
757
  const act = (0, contained_1.presence)(decision);
711
758
  const found = [];
712
- if (vba)
759
+ if (vba && !vbaDecision)
713
760
  found.push(ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, loc, data, act));
714
761
  // A macro sheet becomes an empty worksheet, so the indexes of the sheets after it stay right.
715
762
  if ((exports.TYPE.macrosheet.has(ct) || as(AS.macrosheet)) && ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.XlmMacrosheet, loc, data).action === 'strip')
@@ -718,8 +765,12 @@ async function partRules(ctx, p) {
718
765
  found.push(ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleObject, loc, data, act));
719
766
  if (pkg)
720
767
  found.push(ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Package, loc, data, act));
721
- if (control || (controlBinary && !p.claimed))
722
- found.push(ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, loc, data, control ? undefined : act));
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(ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, loc, { ...data, ...why }, control ? undefined : act));
773
+ }
723
774
  if (p.ole && !vba && !ole && !control && !controlBinary && !pkg)
724
775
  found.push(ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleFile, loc, data, act));
725
776
  if (decision ? !decision.keep : found.some(f => f.action === 'strip'))
@@ -727,6 +778,109 @@ async function partRules(ctx, p) {
727
778
  else if (decision?.replacement)
728
779
  ctx.plan.replace.set(key, decision.replacement);
729
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
+ }
730
884
  /** Parts that no chain of internal relationships reaches from the package. */
731
885
  function unreferenced(ctx, parts, relsOf, roots) {
732
886
  const reached = new Set(roots);
@@ -0,0 +1,27 @@
1
+ import type { SpillSink } from './io';
2
+ import type { ByteSource } from './types';
3
+ /** What the rewrite of a PowerPoint Document stream changes, each record by its offset in the stream as read. */
4
+ export interface PresentationEdits {
5
+ /** Records that go, with everything inside them. */
6
+ remove: Set<number>;
7
+ /** Records whose body is replaced, the header written for the new length. */
8
+ replace: Map<number, Uint8Array>;
9
+ /** Persist objects, by persist id, that the new persist directory leaves out. */
10
+ dropPersist: Set<number>;
11
+ /** The presentation is rewritten only to decrypt it. A rewrite of an encrypted presentation always decrypts it. */
12
+ decrypt: boolean;
13
+ }
14
+ export declare const noPresentationEdits: () => PresentationEdits;
15
+ export declare const hasPresentationEdits: (e: PresentationEdits) => boolean;
16
+ /** The current edit as its UserEditAtom (MS-PPT 2.3.3) gives it. */
17
+ export interface CurrentEdit {
18
+ /** The atom's body, kept but for the offsets the rewrite writes anew. */
19
+ body: Uint8Array;
20
+ }
21
+ /**
22
+ * Writes a PowerPoint Document stream again as a full save (MS-PPT 2.1.2): each persist object the current edit
23
+ * reaches, in persist id order, then one persist directory for them and one UserEditAtom with no edit before it.
24
+ * `source` is the stream as read, decrypted when it was encrypted, and `persist` maps each persist id to the offset of
25
+ * its object. Returns the offset of the new UserEditAtom, which the Current User stream points at.
26
+ */
27
+ export declare function rewritePresentation(source: ByteSource, size: number, persist: ReadonlyMap<number, number>, current: CurrentEdit, edits: PresentationEdits, sink: SpillSink, deadline?: number): Promise<number>;
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hasPresentationEdits = exports.noPresentationEdits = void 0;
4
+ exports.rewritePresentation = rewritePresentation;
5
+ const errors_1 = require("./errors");
6
+ const noPresentationEdits = () => ({ remove: new Set(), replace: new Map(), dropPersist: new Set(), decrypt: false });
7
+ exports.noPresentationEdits = noPresentationEdits;
8
+ const hasPresentationEdits = (e) => e.decrypt || e.remove.size > 0 || e.replace.size > 0 || e.dropPersist.size > 0;
9
+ exports.hasPresentationEdits = hasPresentationEdits;
10
+ const USER_EDIT = 0x0ff5;
11
+ const PERSIST_DIRECTORY = 0x1772;
12
+ const u32 = (b, at) => (b[at] | (b[at + 1] << 8) | (b[at + 2] << 16) | (b[at + 3] << 24)) >>> 0;
13
+ const le32 = (n) => Uint8Array.of(n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff);
14
+ function head(verInst, type, length) {
15
+ return Uint8Array.of(verInst & 0xff, verInst >> 8, type & 0xff, type >> 8, ...le32(length));
16
+ }
17
+ /**
18
+ * The bytes of one record, from `offset` in `source`, with the edits inside it made. A container is written from its
19
+ * children, so its length follows what they become; a record without edits inside is copied as it is.
20
+ */
21
+ async function recordBytes(source, offset, end, edits, touched, deadline) {
22
+ (0, errors_1.checkTime)(deadline);
23
+ const h = await source.read(offset, 8);
24
+ const verInst = h[0] | (h[1] << 8);
25
+ const type = h[2] | (h[3] << 8);
26
+ const length = Math.min(u32(h, 4), end - offset - 8);
27
+ if (edits.remove.has(offset))
28
+ return [];
29
+ const replaced = edits.replace.get(offset);
30
+ if (replaced)
31
+ return [head(verInst, type, replaced.length), replaced];
32
+ const bodyEnd = offset + 8 + length;
33
+ if ((verInst & 0x0f) !== 0x0f || !touched(offset + 8, bodyEnd))
34
+ return [head(verInst, type, length), await source.read(offset + 8, length)];
35
+ const parts = [];
36
+ let total = 0;
37
+ for (let pos = offset + 8; pos + 8 <= bodyEnd;) {
38
+ const kh = await source.read(pos, 8);
39
+ const kidEnd = Math.min(bodyEnd, pos + 8 + u32(kh, 4));
40
+ for (const p of await recordBytes(source, pos, kidEnd, edits, touched, deadline)) {
41
+ parts.push(p);
42
+ total += p.length;
43
+ }
44
+ pos = kidEnd;
45
+ }
46
+ return [head(verInst, type, total), ...parts];
47
+ }
48
+ /**
49
+ * Writes a PowerPoint Document stream again as a full save (MS-PPT 2.1.2): each persist object the current edit
50
+ * reaches, in persist id order, then one persist directory for them and one UserEditAtom with no edit before it.
51
+ * `source` is the stream as read, decrypted when it was encrypted, and `persist` maps each persist id to the offset of
52
+ * its object. Returns the offset of the new UserEditAtom, which the Current User stream points at.
53
+ */
54
+ async function rewritePresentation(source, size, persist, current, edits, sink, deadline) {
55
+ const marks = [...edits.remove, ...edits.replace.keys()].sort((a, b) => a - b);
56
+ const touched = (from, to) => {
57
+ let lo = 0;
58
+ let hi = marks.length;
59
+ while (lo < hi) {
60
+ const mid = (lo + hi) >> 1;
61
+ if (marks[mid] < from)
62
+ lo = mid + 1;
63
+ else
64
+ hi = mid;
65
+ }
66
+ return lo < marks.length && marks[lo] < to;
67
+ };
68
+ const written = new Map();
69
+ let pos = 0;
70
+ for (const [id, offset] of [...persist].sort((a, b) => a[0] - b[0])) {
71
+ if (edits.dropPersist.has(id) || offset + 8 > size)
72
+ continue;
73
+ const parts = await recordBytes(source, offset, size, edits, touched, deadline);
74
+ if (!parts.length)
75
+ continue;
76
+ written.set(id, pos);
77
+ for (const p of parts) {
78
+ await sink.write(p);
79
+ pos += p.length;
80
+ }
81
+ }
82
+ // The directory: runs of consecutive ids, each a 20-bit first id and a 12-bit count, then their offsets.
83
+ const ids = [...written.keys()].sort((a, b) => a - b);
84
+ const dir = [];
85
+ for (let i = 0; i < ids.length;) {
86
+ let n = 1;
87
+ while (i + n < ids.length && ids[i + n] === ids[i] + n && n < 0xfff)
88
+ n++;
89
+ dir.push(...le32((ids[i] & 0xfffff) | (n << 20)));
90
+ for (let k = 0; k < n; k++)
91
+ dir.push(...le32(written.get(ids[i + k]) ?? 0));
92
+ i += n;
93
+ }
94
+ const dirAt = pos;
95
+ const dirRecord = Uint8Array.from([...head(0, PERSIST_DIRECTORY, dir.length), ...dir]);
96
+ await sink.write(dirRecord);
97
+ pos += dirRecord.length;
98
+ // The UserEditAtom: no edit before it, the new directory, and no encryption session once decrypted.
99
+ const body = Uint8Array.from(current.body.subarray(0, edits.decrypt ? 28 : current.body.length));
100
+ body.set(le32(0), 8);
101
+ body.set(le32(dirAt), 12);
102
+ const editAt = pos;
103
+ await sink.write(Uint8Array.from([...head(0, USER_EDIT, body.length), ...body]));
104
+ return editAt;
105
+ }
package/dist/ppt.d.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  import { type CfbEntry, CompoundFile } from './cfb';
2
2
  import { type RunContext } from './context';
3
+ import { CompoundPlan } from './plan';
3
4
  /**
4
5
  * Inspects a PowerPoint 97 to 2003 presentation. PowerPoint finds its objects through the persist directory that the
5
6
  * current edit names. This reads every record at the top level of the stream as well as every object the persist
6
7
  * directories name, so an object that one route hides is still read.
8
+ *
9
+ * With `plan`, what the strip findings name is removed by a full save of the stream: the persist objects the current
10
+ * edit reaches, written again with the records that go left out, a new persist directory and a new UserEditAtom.
7
11
  */
8
- export declare function powerPointRules(ctx: RunContext, cfb: CompoundFile, stream: CfbEntry): Promise<void>;
12
+ export declare function powerPointRules(ctx: RunContext, cfb: CompoundFile, stream: CfbEntry, plan?: CompoundPlan): Promise<void>;