@patrickdk77/office-defuse 0.2.0 → 0.2.1

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 CHANGED
@@ -91,7 +91,7 @@ A ZIP with both markers is rejected as PACKAGE/AMBIGUOUS_FORMAT, since Office co
91
91
 
92
92
  A .xlsb gets the package checks. Its sheets are binary records, and the formulas in them go unread. Its external link parts are binary too, and office-defuse reads their link records for DDE links. A .odb gets every ODF check, and the database under `database/` goes unread.
93
93
 
94
- A file that starts with the compound file signature is a compound file, MS-CFB. The streams and storages at its root name the format: `WordDocument` for Word, `Workbook` or `Book` for Excel, `PowerPoint Document`, `VisioDocument`, `__properties_version1.0` for an Outlook message, a `Quill` storage or Publisher's class id beside a `Contents` stream for Publisher, and a `Props` stream beside a storage named by a number for Project. Word's FIB says whether a document is a template, and Excel's records whether a workbook is a template or an add-in. An Outlook template has a class id of its own. A PowerPoint 97 to 2003 template or show reports its format as .ppt, and a Visio stencil or template as .vsd.
94
+ A file that starts with the compound file signature is a compound file, MS-CFB. The streams and storages at its root name the format: `WordDocument` for Word, `Workbook` or `Book` for Excel, `PowerPoint Document`, `VisioDocument`, `__properties_version1.0` for an Outlook message, a `Quill` storage or Publisher's class id beside a `Contents` stream for Publisher, and a `Props` stream beside a storage named by a number for Project. Word's FIB says whether a document is a template, and Excel's records whether a workbook is a template or an add-in. Neither needs the password of an encrypted file. An Outlook template has a class id of its own. A PowerPoint 97 to 2003 template or show reports its format as .ppt, and a Visio stencil or template as .vsd.
95
95
 
96
96
  A compound file with two of these is PACKAGE/AMBIGUOUS_FORMAT. One with none is PACKAGE/UNRECOGNIZED, and so is a `WordDocument` stream that holds no Word 6.0 to 2003 FIB. A PowerPoint 4.0 file, a bare OLE object and a `vbaProject.bin` on its own all land there.
97
97
 
@@ -101,6 +101,19 @@ A file that starts as XML, after any byte order mark and white space, is a flat
101
101
 
102
102
  A file that starts with `{\rt` is RTF, as Word takes it. A compound file or RTF file in which the ZIP reader also finds an archive is PACKAGE/AMBIGUOUS_FORMAT. A file with the compound file signature whose compound file does not read, but whose ZIP archive does, is that ZIP archive with LEADING_BYTES.
103
103
 
104
+ ## Detection
105
+
106
+ `detectOffice(bytes, options)` and `detectOfficeSource(source, options)` tell a router whether office-defuse reads a file. They return `{ mimeType, extension, offset }`, or undefined. `extension` and `mimeType` are the `format` and `mimeType` an inspection reports. The inspection and detection name a file with the same functions. Detection opens the container and reads only what names the format, without the rules, the plugins or the rewrite:
107
+
108
+ - the streams at a compound file's root, and for Word and Excel the FIB or the workbook's globals, which say whether it is a template or an add-in
109
+ - a package's `[Content_Types].xml` and package relationships, or its `mimetype` entry and manifest
110
+ - a flat file's root element, and for Flat OPC each part's name and content type and the package relationships
111
+ - the first bytes of RTF
112
+
113
+ Detection does not claim a file whose format office-defuse cannot name. That covers a ZIP archive with no Office marker and a package of another kind, such as a NuGet package or a StarOffice file. It covers a compound file with no Office document at its root, such as an installer or a bare OLE object, and a file that is a ZIP archive and a compound file at once. An encrypted OOXML package names its format only once it is decrypted, and detection has no password. When VelvetSweatshop opens the package, detection names the format inside. Otherwise it claims the package as a compound file, application/x-ole-storage with an empty extension, since the file is office-defuse's whatever the password turns out to be. The inspection then decrypts it with the run's password, or rejects it as encrypted. Detection claims a file it can name even when the file is damaged further on, and the inspection then rejects it.
114
+
115
+ `offset` is where the ZIP data starts in an archive with other data in front, as the ZIP reader finds it, and 0 for anything else. The name and declared type play no part, so `heuristic` is never set. `detectOfficeSource` leaves the source open and throws with the `DEFUSE_LIMIT` code once `options.deadline` passes. An I/O error from the source comes back unchanged. Naming a Flat OPC package packs its parts into a ZIP archive, which spills into `options.tempDir` past `options.memoryThreshold`. `officePlugin` accepts exactly what detection claims.
116
+
104
117
  ## What it reports
105
118
 
106
119
  In a ZIP package:
@@ -241,7 +254,7 @@ The findings a plugin returns go into the report with their `location` after the
241
254
 
242
255
  The plugin context has `depth`, the file's depth in the upload, and `deadline`, which every file of the upload shares. An error with `code: 'DEFUSE_LIMIT'` or `code: 'DEFUSE_IO'` from a plugin stops the whole upload. At depth 0, a limit becomes LIMIT/TIME or LIMIT/NESTING_DEPTH, and an I/O error is thrown as its cause.
243
256
 
244
- `officePlugin(options)` defuses Office and OpenDocument files inside other files. It accepts a file by its content alone: a compound file, RTF, a ZIP archive with `[Content_Types].xml`, a `mimetype` entry or `META-INF/manifest.xml`, or XML whose root is a flat OpenDocument document or a Flat OPC package. It runs disarm at the context's depth with the context's deadline. Without `options` it takes the options of the office-defuse run that found the file, or none when another package found it. A clean file comes back `passed`, a defused one `scrubbed`, both with the rewritten bytes, and a rejected one `removed`. The findings it returns carry their `weight`.
257
+ `officePlugin(options)` defuses Office and OpenDocument files inside other files. It accepts the files [`detectOffice`](#detection) claims, by their content alone, an encrypted OOXML package included, which it decrypts with the run's password. It runs disarm at the context's depth with the context's deadline. Without `options` it takes the options of the office-defuse run that found the file, or none when another package found it. A clean file comes back `passed`, a defused one `scrubbed`, both with the rewritten bytes, and a rejected one `removed`. The findings it returns carry their `weight`.
245
258
 
246
259
  ```js
247
260
  const { disarmOffice, officePlugin } = require('@patrickdk77/office-defuse');
@@ -0,0 +1,13 @@
1
+ import { type ByteSource, type DetectOptions, type FormatDetection } from './types';
2
+ /**
3
+ * The format office-defuse reads `source` as, when it can name it from the content: the format and MIME type an
4
+ * inspection reports, read the way the inspection reads them, without the rules, the plugins or the rewrite. A ZIP
5
+ * archive, compound file or XML file whose format it cannot name, such as a plain archive, an installer or a package
6
+ * of another kind, is not claimed. A file it names but cannot read past that point is claimed, and an inspection then
7
+ * rejects it. So is an encrypted Office Open XML package no password at hand opens, as application/x-ole-storage with
8
+ * no extension, since only the password says which format is inside. The offset is that of the ZIP data in an archive with data in front, and 0 otherwise. The name and the
9
+ * declared type play no part. The source stays open.
10
+ */
11
+ export declare function detectOfficeSource(source: ByteSource, options?: DetectOptions): Promise<FormatDetection | undefined>;
12
+ /** `detectOfficeSource` over bytes in memory. */
13
+ export declare function detectOffice(bytes: Uint8Array, options?: DetectOptions): Promise<FormatDetection | undefined>;
package/dist/detect.js ADDED
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectOfficeSource = detectOfficeSource;
4
+ exports.detectOffice = detectOffice;
5
+ const engine_1 = require("./engine");
6
+ const errors_1 = require("./errors");
7
+ const flat_1 = require("./flat");
8
+ const formats_1 = require("./formats");
9
+ const io_1 = require("./io");
10
+ const ole_1 = require("./ole");
11
+ const package_1 = require("./package");
12
+ const types_1 = require("./types");
13
+ /**
14
+ * The format of a file whose container `openContainer` read, named by the steps the inspection names it by, without
15
+ * the rules: the root of a compound file, and for Word and Excel whether it is a template or an add-in; the markers,
16
+ * [Content_Types].xml and package relationships of a package; the root of a flat file; or RTF. An encrypted package
17
+ * names its format when the passwords the run tries, which without a password is VelvetSweatshop alone, open it.
18
+ */
19
+ async function nameContainer(ctx, container, src, size, limits) {
20
+ if (container.kind === 'rtf')
21
+ return formats_1.RTF_FORMAT;
22
+ if (container.kind === 'xml')
23
+ return (0, flat_1.nameFlat)(ctx, src, size, limits);
24
+ if (container.kind === 'zip')
25
+ return (0, package_1.namePackage)(ctx.withZip(container.zip));
26
+ const cfb = container.cfb;
27
+ const family = (0, ole_1.mainFamily)(cfb);
28
+ const format = await (0, ole_1.compoundFormat)(cfb, family, ctx.settings.deadline);
29
+ if (format)
30
+ return format;
31
+ try {
32
+ return await (0, package_1.namePackage)(ctx.withZip(await (0, ole_1.openEncryptedPackage)(ctx, cfb)));
33
+ }
34
+ catch (e) {
35
+ // An encrypted package that no password at hand opens is still office-defuse's. Only the password says which
36
+ // format is inside, and the inspection decrypts it with the run's password or rejects it as encrypted.
37
+ if (family === 'encrypted' && e instanceof errors_1.StopError && e.category === types_1.OfficeCategory.Encrypted)
38
+ return ENCRYPTED_PACKAGE;
39
+ throw e;
40
+ }
41
+ }
42
+ /** An encrypted Office Open XML package whose format no password at hand reveals: a compound file, with no extension of its own. */
43
+ const ENCRYPTED_PACKAGE = { format: '', mimeType: 'application/x-ole-storage' };
44
+ /**
45
+ * The format office-defuse reads `source` as, when it can name it from the content: the format and MIME type an
46
+ * inspection reports, read the way the inspection reads them, without the rules, the plugins or the rewrite. A ZIP
47
+ * archive, compound file or XML file whose format it cannot name, such as a plain archive, an installer or a package
48
+ * of another kind, is not claimed. A file it names but cannot read past that point is claimed, and an inspection then
49
+ * rejects it. So is an encrypted Office Open XML package no password at hand opens, as application/x-ole-storage with
50
+ * no extension, since only the password says which format is inside. The offset is that of the ZIP data in an archive with data in front, and 0 otherwise. The name and the
51
+ * declared type play no part. The source stays open.
52
+ */
53
+ async function detectOfficeSource(source, options = {}) {
54
+ const run = (0, engine_1.newRun)({ tempDir: options.tempDir, memoryThreshold: options.memoryThreshold }, 0, options.deadline);
55
+ const src = (0, engine_1.guarded)(source);
56
+ try {
57
+ const { limits, settings, ctx } = (0, engine_1.runContext)(run);
58
+ const size = await src.size();
59
+ (0, errors_1.checkTime)(run.deadline);
60
+ const container = await (0, engine_1.openContainer)(src, size, limits, settings);
61
+ const format = await nameContainer(ctx, container, src, size, limits);
62
+ if (run.temp.ioError !== undefined)
63
+ throw run.temp.ioError;
64
+ return { mimeType: format.mimeType, extension: format.format, offset: container.kind === 'zip' ? container.zip.start : 0 };
65
+ }
66
+ catch (e) {
67
+ if (src.failure !== undefined)
68
+ throw src.failure;
69
+ if (run.temp.ioError !== undefined)
70
+ throw run.temp.ioError;
71
+ if ((0, errors_1.stopsTree)(e) !== undefined || (0, errors_1.isIoError)(e))
72
+ throw e;
73
+ // Any other failure, as in an inspection, means office-defuse cannot name the file's format.
74
+ return undefined;
75
+ }
76
+ finally {
77
+ await (0, engine_1.endRun)(run);
78
+ }
79
+ }
80
+ /** `detectOfficeSource` over bytes in memory. */
81
+ function detectOffice(bytes, options = {}) {
82
+ return detectOfficeSource((0, io_1.bufferSource)(bytes), options);
83
+ }
package/dist/engine.d.ts CHANGED
@@ -1,4 +1,57 @@
1
- import { type ByteSink, type ByteSource, type OfficeDisarmResult, type OfficeInspection, type OfficeOptions } from './types';
1
+ import { CompoundFile } from './cfb';
2
+ import { RunContext, type RunSettings, type RunState } from './context';
3
+ import { type ByteSink, type ByteSource, type OfficeDisarmResult, type OfficeInspection, type OfficeLimits, type OfficeOptions } from './types';
4
+ import { ZipArchive } from './zip';
5
+ declare const DEFAULT_LIMITS: {
6
+ entries: number;
7
+ decompressedBytes: number;
8
+ totalDecompressedBytes: number;
9
+ xmlDepth: number;
10
+ xmlTokenSize: number;
11
+ xmlEntityDepth: number;
12
+ xmlEntityExpansions: number;
13
+ xmlEntitySize: number;
14
+ rtfDepth: number;
15
+ };
16
+ export type Limits = typeof DEFAULT_LIMITS & Pick<OfficeLimits, 'fileSize' | 'timeMs' | 'nestingDepth'>;
17
+ /**
18
+ * Wraps the source for one run. A read that returns more or fewer bytes than the source's size allows fails with
19
+ * code EIO, since the reader would take the shifted bytes for the file. Any error the source throws is kept, and the
20
+ * engine throws it instead of a verdict on the file.
21
+ */
22
+ export declare function guarded(source: ByteSource): ByteSource & {
23
+ failure?: unknown;
24
+ };
25
+ export type Container = {
26
+ kind: 'zip';
27
+ zip: ZipArchive;
28
+ } | {
29
+ kind: 'cfb';
30
+ cfb: CompoundFile;
31
+ } | {
32
+ kind: 'rtf';
33
+ } | {
34
+ kind: 'xml';
35
+ };
36
+ /**
37
+ * Reads the file's container. A file that starts with the compound file signature is read as one, and one that
38
+ * starts with {\rt is RTF, which is how Word tells them. The ZIP reader runs too, from the end: a file both read is
39
+ * ambiguous, since Office goes by the first bytes and a ZIP reader by the last. A compound file that does not read is
40
+ * a ZIP archive with data in front, if the ZIP reader read one. Word reads any file that starts with {\rt as RTF, so
41
+ * that does not fail. A file that starts as XML is a flat OpenDocument file or a Flat OPC package, and one that is a
42
+ * ZIP archive as well is ambiguous.
43
+ */
44
+ export declare function openContainer(src: ByteSource, size: number, limits: Limits, settings: RunSettings): Promise<Container>;
45
+ /** A run of the engine over one file at `depth`, with the deadline the whole upload shares. */
46
+ export declare function newRun(options: OfficeOptions, depth: number, deadline: number | undefined, verify?: RunState['verify']): RunState;
47
+ /** Releases what a run holds: the copies plugins returned and the temporary directory. */
48
+ export declare function endRun(run: RunState): Promise<void>;
49
+ /** The limits, settings and context a run reads its file with, from the run's options. */
50
+ export declare function runContext(run: RunState): {
51
+ limits: Limits;
52
+ settings: RunSettings;
53
+ ctx: RunContext;
54
+ };
2
55
  /** Inspects an Office or OpenDocument file without changing it. The source is closed when the run ends. */
3
56
  export declare function inspectOfficeSource(source: ByteSource, options?: OfficeOptions): Promise<OfficeInspection>;
4
57
  /** Inspects Office or OpenDocument bytes. */
@@ -14,3 +67,4 @@ export declare function disarmAtDepth(source: ByteSource, sink: ByteSink, option
14
67
  export declare function disarmOffice(bytes: Uint8Array, options?: OfficeOptions): Promise<OfficeDisarmResult & {
15
68
  bytes?: Uint8Array;
16
69
  }>;
70
+ export {};
package/dist/engine.js CHANGED
@@ -1,5 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.guarded = guarded;
4
+ exports.openContainer = openContainer;
5
+ exports.newRun = newRun;
6
+ exports.endRun = endRun;
7
+ exports.runContext = runContext;
3
8
  exports.inspectOfficeSource = inspectOfficeSource;
4
9
  exports.inspectOffice = inspectOffice;
5
10
  exports.disarmOfficeSource = disarmOfficeSource;
@@ -178,6 +183,21 @@ async function endRun(run) {
178
183
  await run.temp.cleanup();
179
184
  }
180
185
  }
186
+ /** The limits, settings and context a run reads its file with, from the run's options. */
187
+ function runContext(run) {
188
+ const options = run.options;
189
+ const limits = resolveLimits(options.limits);
190
+ const password = options.password;
191
+ const settings = {
192
+ entries: limits.entries,
193
+ decompressedBytes: limits.decompressedBytes,
194
+ totalDecompressedBytes: limits.totalDecompressedBytes,
195
+ rtfDepth: limits.rtfDepth,
196
+ deadline: run.deadline,
197
+ passwords: typeof password === 'string' ? [password] : (password ?? []),
198
+ };
199
+ return { limits, settings, ctx: new context_1.RunContext(new findings_1.FindingFactory(options.actionOverrides), xmlLimitsOf(limits), settings, run) };
200
+ }
181
201
  /** The finding a limit error from this package or any other gives at depth 0. */
182
202
  function limitFinding(factory, e) {
183
203
  return factory.make(types_1.OfficeCategory.Limit, e.limit === 'nesting' ? types_1.OfficeDetail.NestingDepth : types_1.OfficeDetail.Time);
@@ -188,21 +208,11 @@ function limitFinding(factory, e) {
188
208
  */
189
209
  async function analyze(run, source) {
190
210
  const options = run.options;
191
- const limits = resolveLimits(options.limits);
192
211
  const deadline = run.deadline;
193
- const factory = new findings_1.FindingFactory(options.actionOverrides);
212
+ const { limits, settings, ctx } = runContext(run);
213
+ const factory = ctx.factory;
194
214
  const findings = [];
195
215
  const src = guarded(source);
196
- const password = options.password;
197
- const settings = {
198
- entries: limits.entries,
199
- decompressedBytes: limits.decompressedBytes,
200
- totalDecompressedBytes: limits.totalDecompressedBytes,
201
- rtfDepth: limits.rtfDepth,
202
- deadline,
203
- passwords: typeof password === 'string' ? [password] : (password ?? []),
204
- };
205
- const ctx = new context_1.RunContext(factory, xmlLimitsOf(limits), settings, run);
206
216
  const isOutput = run.verify !== undefined;
207
217
  try {
208
218
  if (limits.nestingDepth !== undefined && run.depth > limits.nestingDepth)
package/dist/flat.d.ts CHANGED
@@ -12,16 +12,16 @@ export declare const FLAT_ODF_PART = "office:document";
12
12
  export declare const FLAT_OPC_FORMAT: Format;
13
13
  /** Whether the first bytes of a file read as XML: a byte order mark, white space and then "<". */
14
14
  export declare function startsAsXml(head: Uint8Array): boolean;
15
- /**
16
- * Whether the start of an XML file names a flat OpenDocument document or a Flat OPC package as its root element. The
17
- * reading of the whole file decides; this tells a router which package to try.
18
- */
19
- export declare function isFlatHead(text: string): boolean;
20
15
  /**
21
16
  * Inspects a flat XML file: flat OpenDocument, read with the OpenDocument rules over its one part, or Flat OPC,
22
17
  * which is unpacked into a package of its parts and read with the OOXML rules. Returns what the rewrite needs.
23
18
  */
24
19
  export declare function inspectFlat(ctx: RunContext, src: ByteSource, size: number, zipLimits: ZipLimits): Promise<Output | undefined>;
20
+ /**
21
+ * Names a flat file's format as its inspection does, without the rules: flat OpenDocument by its root element, and a
22
+ * Flat OPC package once the package its parts make names an OOXML format.
23
+ */
24
+ export declare function nameFlat(ctx: RunContext, src: ByteSource, size: number, zipLimits: ZipLimits): Promise<Format>;
25
25
  /** Writes a flat OpenDocument file without what the plan removes. */
26
26
  export declare function writeFlatOdf(run: RunState, src: ByteSource, plan: PackagePlan, limits: XmlLimits, sink: ByteSink): Promise<void>;
27
27
  /**
package/dist/flat.js CHANGED
@@ -2,8 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FLAT_OPC_FORMAT = exports.FLAT_ODF_PART = exports.PKG_NS = void 0;
4
4
  exports.startsAsXml = startsAsXml;
5
- exports.isFlatHead = isFlatHead;
6
5
  exports.inspectFlat = inspectFlat;
6
+ exports.nameFlat = nameFlat;
7
7
  exports.writeFlatOdf = writeFlatOdf;
8
8
  exports.writeFlatOpc = writeFlatOpc;
9
9
  const node_util_1 = require("node:util");
@@ -51,16 +51,6 @@ function startsAsXml(head) {
51
51
  const text = new node_util_1.TextDecoder(found.encoding, { fatal: false }).decode(head.subarray(found.bom));
52
52
  return text.trimStart().startsWith('<');
53
53
  }
54
- /**
55
- * Whether the start of an XML file names a flat OpenDocument document or a Flat OPC package as its root element. The
56
- * reading of the whole file decides; this tells a router which package to try.
57
- */
58
- function isFlatHead(text) {
59
- const body = text.replace(/^\uFEFF/, '').replace(/^(?:\s|<\?[\s\S]*?\?>|<!--[\s\S]*?-->|<!DOCTYPE[^>]*>)*/, '');
60
- const qname = /^<([^\s/>]+)/.exec(body)?.[1] ?? '';
61
- const local = qname.slice(qname.indexOf(':') + 1);
62
- return (local === 'document' && /urn:oasis:names:tc:opendocument:xmlns:office:1\.\d/i.test(text)) || (local === 'package' && text.includes(exports.PKG_NS));
63
- }
64
54
  /** Decodes base64 that arrives in pieces of any size, white space and line ends included. */
65
55
  class Base64 {
66
56
  rest = '';
@@ -141,6 +131,7 @@ class FlatOdf {
141
131
  class FlatOpc {
142
132
  run;
143
133
  pending;
134
+ keep;
144
135
  parts = [];
145
136
  /** Where the pkg:package start tag begins, which is where the prolog ends. */
146
137
  rootStart = 0;
@@ -148,9 +139,12 @@ class FlatOpc {
148
139
  xmlDepth = 0;
149
140
  b64;
150
141
  scopes = [];
151
- constructor(run, pending) {
142
+ constructor(run, pending,
143
+ /** The parts whose content is kept, by name. The others keep only their name, content type and attributes. */
144
+ keep = () => true) {
152
145
  this.run = run;
153
146
  this.pending = pending;
147
+ this.keep = keep;
154
148
  }
155
149
  start(el) {
156
150
  this.scopes.push(el.namespaces);
@@ -182,7 +176,7 @@ class FlatOpc {
182
176
  this.part.xml = { start: el.end, end: el.end, inherited };
183
177
  this.xmlDepth = el.depth;
184
178
  }
185
- else if (el.depth === 3 && this.part && el.local === 'binaryData') {
179
+ else if (el.depth === 3 && this.part && el.local === 'binaryData' && this.keep(this.part.name)) {
186
180
  const sink = new io_1.SpillSink(this.run.temp, this.run.threshold);
187
181
  this.run.held.push(sink);
188
182
  this.part.binary = sink;
@@ -210,6 +204,23 @@ class FlatOpc {
210
204
  }
211
205
  }
212
206
  }
207
+ /**
208
+ * The format a flat file's root element names: a flat OpenDocument format by its office:mimetype, or 'opc' for a Flat
209
+ * OPC package, whose format the package its parts make names. Throws for any other root, or a flat OpenDocument media
210
+ * type this package does not read.
211
+ */
212
+ function flatRoot(el) {
213
+ if ((0, odf_1.odfNamespace)(el.ns) === odf_1.NS.office && el.local === 'document') {
214
+ const type = (0, odf_1.oattr)(el, odf_1.NS.office, 'mimetype') ?? '';
215
+ const format = FLAT_ODF.get(type);
216
+ if (!format)
217
+ throw new errors_1.StopError(types_1.OfficeCategory.Package, types_1.OfficeDetail.Unrecognized, 'The flat OpenDocument media type is not one this package reads', { mediaType: (0, context_1.clip)(type) });
218
+ return format;
219
+ }
220
+ if (el.ns === exports.PKG_NS && el.local === 'package')
221
+ return 'opc';
222
+ throw new errors_1.StopError(types_1.OfficeCategory.Package, types_1.OfficeDetail.Unrecognized, 'The XML file is neither flat OpenDocument nor Flat OPC', { root: (0, context_1.clip)(el.qname), namespace: (0, context_1.clip)(el.ns) });
223
+ }
213
224
  /** Reads the root element and hands everything to the rules of the flat format it names. */
214
225
  class FlatReader {
215
226
  ctx;
@@ -223,19 +234,14 @@ class FlatReader {
223
234
  }
224
235
  start(el) {
225
236
  if (el.depth === 1) {
226
- if ((0, odf_1.odfNamespace)(el.ns) === odf_1.NS.office && el.local === 'document') {
227
- const type = (0, odf_1.oattr)(el, odf_1.NS.office, 'mimetype') ?? '';
228
- this.format = FLAT_ODF.get(type);
229
- if (!this.format)
230
- throw new errors_1.StopError(types_1.OfficeCategory.Package, types_1.OfficeDetail.Unrecognized, 'The flat OpenDocument media type is not one this package reads', { mediaType: (0, context_1.clip)(type) });
231
- this.ctx.format = this.format;
232
- this.odf = new FlatOdf(this.ctx, this.ctx.run, this.pending);
233
- }
234
- else if (el.ns === exports.PKG_NS && el.local === 'package') {
237
+ const format = flatRoot(el);
238
+ if (format === 'opc')
235
239
  this.opc = new FlatOpc(this.ctx.run, this.pending);
240
+ else {
241
+ this.format = format;
242
+ this.ctx.format = format;
243
+ this.odf = new FlatOdf(this.ctx, this.ctx.run, this.pending);
236
244
  }
237
- else
238
- throw new errors_1.StopError(types_1.OfficeCategory.Package, types_1.OfficeDetail.Unrecognized, 'The XML file is neither flat OpenDocument nor Flat OPC', { root: (0, context_1.clip)(el.qname), namespace: (0, context_1.clip)(el.ns) });
239
245
  }
240
246
  this.odf?.start(el);
241
247
  this.opc?.start(el);
@@ -350,7 +356,6 @@ function partXml(text, inherited) {
350
356
  * which is unpacked into a package of its parts and read with the OOXML rules. Returns what the rewrite needs.
351
357
  */
352
358
  async function inspectFlat(ctx, src, size, zipLimits) {
353
- const run = ctx.run;
354
359
  const pctx = ctx.withPlan();
355
360
  const pending = new Pending();
356
361
  const reader = new FlatReader(pctx, pending);
@@ -366,7 +371,25 @@ async function inspectFlat(ctx, src, size, zipLimits) {
366
371
  const opc = reader.opc;
367
372
  if (!opc)
368
373
  return undefined;
369
- const xmlParts = opc.parts.filter(p => p.xml);
374
+ const { zip, prolog } = await packFlatOpc(ctx, src, size, opc, zipLimits);
375
+ const zctx = ctx.withZip(zip);
376
+ const contentTypes = zip.get('[Content_Types].xml');
377
+ if (!contentTypes)
378
+ return undefined;
379
+ const inner = await (0, ooxml_1.inspectOoxml)(zctx, contentTypes, exports.FLAT_OPC_FORMAT);
380
+ const layout = {
381
+ instructions: [...prolog.matchAll(/<\?mso-application\s[^?]*\?>/g)].map(m => m[0]),
382
+ extra: new Map(opc.parts.map(p => [(0, zip_1.nameKey)(p.name), p.extra])),
383
+ };
384
+ return { kind: 'flatopc', zip, plan: zctx.plan, format: inner, layout };
385
+ }
386
+ /**
387
+ * Packs the parts of a Flat OPC package into a ZIP archive, with a [Content_Types].xml that gives each its content
388
+ * type, and returns it with the text before the package element. A part the reader did not keep goes in empty.
389
+ */
390
+ async function packFlatOpc(ctx, src, size, opc, zipLimits) {
391
+ const run = ctx.run;
392
+ const xmlParts = opc.parts.filter(p => p.xml && opc.keep(p.name));
370
393
  const cut = await cutText(src, size, ctx.settings.deadline, opc.rootStart, xmlParts.map(p => p.xml ?? { start: 0, end: 0 }));
371
394
  // The parts go into a package of their own, with a [Content_Types].xml that gives each its content type.
372
395
  const packed = new io_1.SpillSink(run.temp, run.threshold);
@@ -384,8 +407,9 @@ async function inspectFlat(ctx, src, size, zipLimits) {
384
407
  for (const p of opc.parts) {
385
408
  (0, errors_1.checkTime)(ctx.settings.deadline);
386
409
  let data = new Uint8Array(0);
387
- if (p.xml)
388
- data = partXml(cut.texts[xmlParts.indexOf(p)], p.xml.inherited);
410
+ const cutAt = xmlParts.indexOf(p);
411
+ if (p.xml && cutAt >= 0)
412
+ data = partXml(cut.texts[cutAt], p.xml.inherited);
389
413
  else if (p.binary) {
390
414
  const chunks = [];
391
415
  for await (const c of p.binary.read())
@@ -398,21 +422,66 @@ async function inspectFlat(ctx, src, size, zipLimits) {
398
422
  await packed.close();
399
423
  const packedSource = packed.source();
400
424
  run.sources.push(packedSource);
401
- const zip = await zip_1.ZipArchive.open(packedSource, zipLimits, ctx.settings.deadline);
402
- const zctx = ctx.withZip(zip);
425
+ return { zip: await zip_1.ZipArchive.open(packedSource, zipLimits, ctx.settings.deadline), prolog: cut.prolog };
426
+ }
427
+ /** Stops the reading of a flat file once its root has named a flat OpenDocument format. */
428
+ const ROOT_NAMED = new Error('The root names the format');
429
+ /**
430
+ * Reads a flat file as far as naming its format needs: its root element, and for a Flat OPC package, every part's
431
+ * name, content type and attributes, with the content of the package relationships alone.
432
+ */
433
+ class FlatNamer {
434
+ run;
435
+ pending;
436
+ format;
437
+ opc;
438
+ constructor(run, pending) {
439
+ this.run = run;
440
+ this.pending = pending;
441
+ }
442
+ start(el) {
443
+ if (el.depth === 1) {
444
+ const format = flatRoot(el);
445
+ if (format !== 'opc') {
446
+ this.format = format;
447
+ throw ROOT_NAMED;
448
+ }
449
+ this.opc = new FlatOpc(this.run, this.pending, name => (0, ooxml_1.relsSource)(name) === '');
450
+ }
451
+ this.opc?.start(el);
452
+ }
453
+ text(t) {
454
+ this.opc?.text(t);
455
+ }
456
+ end(el, start) {
457
+ this.opc?.end(el, start);
458
+ }
459
+ }
460
+ /**
461
+ * Names a flat file's format as its inspection does, without the rules: flat OpenDocument by its root element, and a
462
+ * Flat OPC package once the package its parts make names an OOXML format.
463
+ */
464
+ async function nameFlat(ctx, src, size, zipLimits) {
465
+ const pending = new Pending();
466
+ const namer = new FlatNamer(ctx.run, pending);
467
+ try {
468
+ await ctx.parseXml(FLAT_OPC_PART, chunksOf(src, size, ctx.settings.deadline), namer, false, undefined, () => pending.flush());
469
+ }
470
+ catch (e) {
471
+ if (e !== ROOT_NAMED)
472
+ throw e;
473
+ }
474
+ if (namer.format)
475
+ return namer.format;
476
+ const opc = namer.opc;
477
+ if (!opc)
478
+ throw new errors_1.StopError(types_1.OfficeCategory.Package, types_1.OfficeDetail.Unrecognized, 'The XML file has no root element', { reason: 'no root element' });
479
+ const { zip } = await packFlatOpc(ctx, src, size, opc, zipLimits);
403
480
  const contentTypes = zip.get('[Content_Types].xml');
404
481
  if (!contentTypes)
405
- return undefined;
406
- await (0, ooxml_1.inspectOoxml)(zctx, contentTypes);
407
- const inner = ctx.format;
408
- ctx.format = exports.FLAT_OPC_FORMAT;
409
- if (!inner)
410
- return undefined;
411
- const layout = {
412
- instructions: [...cut.prolog.matchAll(/<\?mso-application\s[^?]*\?>/g)].map(m => m[0]),
413
- extra: new Map(opc.parts.map(p => [(0, zip_1.nameKey)(p.name), p.extra])),
414
- };
415
- return { kind: 'flatopc', zip, plan: zctx.plan, format: inner, layout };
482
+ throw new errors_1.StopError(types_1.OfficeCategory.Package, types_1.OfficeDetail.Unrecognized, 'The Flat OPC package has no [Content_Types].xml', { reason: 'no [Content_Types].xml' });
483
+ await (0, ooxml_1.nameOoxml)(ctx.withZip(zip), contentTypes);
484
+ return exports.FLAT_OPC_FORMAT;
416
485
  }
417
486
  /** Writes a flat OpenDocument file without what the plan removes. */
418
487
  async function writeFlatOdf(run, src, plan, limits, sink) {
package/dist/formats.d.ts CHANGED
@@ -15,3 +15,5 @@ export declare const XPS_FORMATS: {
15
15
  };
16
16
  /** OpenDocument formats by the media type in the mimetype entry or the manifest, exactly as written. */
17
17
  export declare const ODF_FORMATS: Map<string, Format>;
18
+ /** RTF, which Word reads in any file that starts with {\rt. */
19
+ export declare const RTF_FORMAT: Format;
package/dist/formats.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ODF_FORMATS = exports.XPS_FORMATS = exports.XPS_SEQUENCE = exports.OOXML_FORMATS = void 0;
3
+ exports.RTF_FORMAT = exports.ODF_FORMATS = exports.XPS_FORMATS = exports.XPS_SEQUENCE = exports.OOXML_FORMATS = void 0;
4
4
  const f = (format, mimeType) => ({ format, mimeType });
5
5
  /** OOXML formats by the content type of the main part, lowercased. */
6
6
  exports.OOXML_FORMATS = new Map([
@@ -48,3 +48,5 @@ exports.ODF_FORMATS = new Map([
48
48
  ['text-master', 'odm'],
49
49
  ['base', 'odb'],
50
50
  ].map(([kind, ext]) => [`application/vnd.oasis.opendocument.${kind}`, f(ext, `application/vnd.oasis.opendocument.${kind}`)]));
51
+ /** RTF, which Word reads in any file that starts with {\rt. */
52
+ exports.RTF_FORMAT = f('rtf', 'application/rtf');
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { detectOffice, detectOfficeSource } from './detect';
1
2
  export { disarmOffice, disarmOfficeSource, inspectOffice, inspectOfficeSource } from './engine';
2
3
  export { allFindingSpecs } from './findings';
3
4
  export { bufferSink, bufferSource, fileSink, fileSource, writableSink } from './io';
package/dist/index.js CHANGED
@@ -14,7 +14,10 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.scoreFindings = exports.DEFAULT_BANDS = exports.COMBINATIONS = exports.officePlugin = exports.writableSink = exports.fileSource = exports.fileSink = exports.bufferSource = exports.bufferSink = exports.allFindingSpecs = exports.inspectOfficeSource = exports.inspectOffice = exports.disarmOfficeSource = exports.disarmOffice = void 0;
17
+ exports.scoreFindings = exports.DEFAULT_BANDS = exports.COMBINATIONS = exports.officePlugin = exports.writableSink = exports.fileSource = exports.fileSink = exports.bufferSource = exports.bufferSink = exports.allFindingSpecs = exports.inspectOfficeSource = exports.inspectOffice = exports.disarmOfficeSource = exports.disarmOffice = exports.detectOfficeSource = exports.detectOffice = void 0;
18
+ var detect_1 = require("./detect");
19
+ Object.defineProperty(exports, "detectOffice", { enumerable: true, get: function () { return detect_1.detectOffice; } });
20
+ Object.defineProperty(exports, "detectOfficeSource", { enumerable: true, get: function () { return detect_1.detectOfficeSource; } });
18
21
  var engine_1 = require("./engine");
19
22
  Object.defineProperty(exports, "disarmOffice", { enumerable: true, get: function () { return engine_1.disarmOffice; } });
20
23
  Object.defineProperty(exports, "disarmOfficeSource", { enumerable: true, get: function () { return engine_1.disarmOfficeSource; } });
package/dist/index.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import cjs from './index.js';
2
- export const { disarmOffice, disarmOfficeSource, inspectOffice, inspectOfficeSource, allFindingSpecs, bufferSink, bufferSource, fileSink, fileSource, writableSink, officePlugin, COMBINATIONS, DEFAULT_BANDS, scoreFindings, OfficeCategory, OfficeDetail, OfficeRisk } = cjs;
2
+ export const { detectOffice, detectOfficeSource, disarmOffice, disarmOfficeSource, inspectOffice, inspectOfficeSource, allFindingSpecs, bufferSink, bufferSource, fileSink, fileSource, writableSink, officePlugin, COMBINATIONS, DEFAULT_BANDS, scoreFindings, OfficeCategory, OfficeDetail, OfficeRisk } = cjs;
3
3
  export default cjs;
package/dist/odf.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { type PlanContext, type ZipContext } from './context';
2
+ import { type Format } from './formats';
2
3
  import { OfficeCategory as C, OfficeDetail as D } from './types';
3
4
  import type { XmlElement, XmlHandler } from './xml';
5
+ import { type ZipEntry } from './zip';
4
6
  export declare const NS: {
5
7
  manifest: string;
6
8
  office: string;
@@ -57,11 +59,31 @@ export declare class EntryRules implements XmlHandler {
57
59
  private scriptEvent;
58
60
  private presentationEvent;
59
61
  }
62
+ export interface Manifest {
63
+ paths: Set<string>;
64
+ root?: string;
65
+ encrypted: boolean;
66
+ }
60
67
  /** The code folder an entry sits in, as the folder's path and its finding. */
61
68
  export declare function folderOf(name: string): {
62
69
  prefix: string;
63
70
  detail: D;
64
71
  category: C;
65
72
  } | undefined;
73
+ /** What names an OpenDocument package: the media type its format comes from, and the entries that give it. */
74
+ export interface OdfNaming {
75
+ format: Format;
76
+ /** The media type the format was named by. */
77
+ mediaType: string;
78
+ mimetype?: ZipEntry;
79
+ manifestEntry?: ZipEntry;
80
+ manifest?: Manifest;
81
+ }
82
+ /**
83
+ * Names an OpenDocument package's format by the media type in its mimetype entry or, without one, its manifest's root
84
+ * entry. Throws when the two name different formats, or the media type is not one this package reads. `layout` hears,
85
+ * with the entry it concerns, of each way the mimetype entry and the manifest leave the layout OpenDocument asks for.
86
+ */
87
+ export declare function nameOdf(ctx: ZipContext, layout?: (location: string, reason: string) => void): Promise<OdfNaming>;
66
88
  /** Inspects an OpenDocument package. */
67
89
  export declare function inspectOdf(ctx: ZipContext): Promise<void>;