archive-codec 1.0.2 → 1.1.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 +44 -7
- package/dist/cfb/detect.cjs +18 -0
- package/dist/cfb/detect.d.cts +4 -0
- package/dist/cfb/detect.d.ts +4 -0
- package/dist/cfb/detect.js +17 -0
- package/dist/cfb/ole-package.cjs +42 -0
- package/dist/cfb/ole-package.d.cts +13 -0
- package/dist/cfb/ole-package.d.ts +13 -0
- package/dist/cfb/ole-package.js +40 -0
- package/dist/cfb/read.cjs +206 -0
- package/dist/cfb/read.d.cts +15 -0
- package/dist/cfb/read.d.ts +15 -0
- package/dist/cfb/read.js +203 -0
- package/dist/index.cjs +20 -79
- package/dist/index.d.cts +7 -33
- package/dist/index.d.ts +7 -33
- package/dist/index.js +7 -72
- package/dist/magic.cjs +9 -0
- package/dist/magic.d.cts +4 -0
- package/dist/magic.d.ts +4 -0
- package/dist/magic.js +8 -0
- package/dist/zip/container.cjs +15 -0
- package/dist/zip/container.d.cts +9 -0
- package/dist/zip/container.d.ts +9 -0
- package/dist/zip/container.js +13 -0
- package/dist/zip/detect.cjs +26 -0
- package/dist/zip/detect.d.cts +6 -0
- package/dist/zip/detect.d.ts +6 -0
- package/dist/zip/detect.js +24 -0
- package/dist/zip/walk.cjs +41 -0
- package/dist/zip/walk.d.cts +20 -0
- package/dist/zip/walk.d.ts +20 -0
- package/dist/zip/walk.js +37 -0
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/ExaDev/documents.js/tree/main/packages/archive-codec) [](https://www.npmjs.com/package/archive-codec) [](https://www.npmjs.com/package/archive-codec) [](https://github.com/ExaDev/documents.js/actions)
|
|
4
4
|
|
|
5
|
-
>
|
|
5
|
+
> ZIP-in-ZIP recursive walking under depth and cumulative decompressed-size guards, and bounded classic OLE compound-file ([MS-CFB]) reading — zero document-format knowledge, the archive and container utility package for the [documents.js family](../../README.md). Worker-isomorphic: the same code runs under Node and inside a Cloudflare Workers isolate.
|
|
6
6
|
|
|
7
7
|
Created for [documents.js#564](https://github.com/ExaDev/documents.js/issues/564): nothing in the ecosystem recursed into a nested archive. Most concretely, OOXML's embedded-object model — a docx/pptx carrying a genuinely separate ZIP blob at `word/embeddings/oleObject1.xlsx` — had no safe handling anywhere, and no package guarded against recursive-archive inputs at all (`byte-codec`'s 512 MiB per-stream inflate cap does not compose across recursion). A new sibling was chosen over extending `byte-codec` (whose charter is byte/image primitives, zero container-format knowledge) or doing it inline in `documents.js` (which would repeat the duplication `byte-codec`'s own extraction was meant to avoid). Its first family consumer is `ooxml.js`'s OLE embedded-object recovery — [documents.js#733](https://github.com/ExaDev/documents.js/issues/733) (pptx, `p:oleObj`) and [documents.js#734](https://github.com/ExaDev/documents.js/issues/734) (docx, `o:OLEObject`): an OLE payload part's bytes are checked through `isZipArchive` and, when they are a ZIP, decoded as a nested OOXML package behind this package's guarded walk — the bounded inflate that populates `document-schema.js`'s `ContentEmbeddedObject`/`ContentEmbeddedObjectBlock` (the same vocabulary odf.js embeds formula sub-documents through) with a genuinely recovered sub-document.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
[documents.js#739](https://github.com/ExaDev/documents.js/issues/739) widened the charter from that ZIP-only v1 scope to the classic OLE compound file, recording the decision explicitly rather than by accident (mirroring the #564 reasoning): real-world Word and PowerPoint files frequently store the embeddee as a `.bin` compound file at `word|ppt/embeddings/oleObject1.bin`, and a CFB reader is container knowledge exactly the way ZIP structure is — sectors, FAT chains, and directory entries, never that any stream is a document. The same recovery now unwraps such a payload's `Package` stream ([MS-OLEDS]'s OLE packaging of the real file) through this package and feeds the packaged ZIP to the unchanged nested decode.
|
|
10
|
+
|
|
11
|
+
Scope: **ZIP containers** (read and write over [`fflate`](https://github.com/101arrowz/fflate), recursive walking of ZIP-in-ZIP entries) and **classic OLE compound files** (bounded [MS-CFB] reading, plus the OLE Package stream unwrapping). **tar and gzip are explicitly out of scope.**
|
|
10
12
|
|
|
11
13
|
## Getting started
|
|
12
14
|
|
|
@@ -14,23 +16,36 @@ Requires Node.js `>=20` and pnpm `11.6.0`.
|
|
|
14
16
|
|
|
15
17
|
```sh
|
|
16
18
|
pnpm install
|
|
17
|
-
pnpm build # tsdown -> dist/ (ESM + CJS + .d.ts)
|
|
19
|
+
pnpm build # tsdown -> dist/ (ESM + CJS + .d.ts, one file set per src module)
|
|
18
20
|
pnpm typecheck # tsc -p tsconfig.json && tsc -p tsconfig.node.json (dual tsconfig)
|
|
19
21
|
pnpm lint # eslint . --fix --cache --max-warnings 0
|
|
20
|
-
pnpm test # vitest run
|
|
21
|
-
pnpm test:watch # vitest
|
|
22
|
+
pnpm test # vitest run --project unit
|
|
23
|
+
pnpm test:watch # vitest --project unit
|
|
22
24
|
pnpm test:workers # vitest run --config vitest.workers.config.ts, inside a real Cloudflare Workers (workerd) isolate
|
|
25
|
+
pnpm test:smoke # builds dist/, then loads the built ESM and CJS barrels and every advertised deep import
|
|
23
26
|
```
|
|
24
27
|
|
|
25
28
|
To run a single test file, pass its path to vitest directly, e.g. `pnpm exec vitest run src/zip/walk.test.ts`.
|
|
26
29
|
|
|
27
30
|
## What it provides
|
|
28
31
|
|
|
32
|
+
Every module is importable by package-relative path as well as through the barrel — `tsdown` builds one dist file per src module (`root: 'src'`, the same layout ooxml.js ships), and `package.json`'s `./*` exports wildcard maps each subpath onto it:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { readCompoundFile } from 'archive-codec/cfb/read';
|
|
36
|
+
import { walkArchive } from 'archive-codec/zip/walk';
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The smoke suite (`test/smoke.test.mjs`) is the guard on that advertisement: it loads each module below from the built `dist/` in both module systems, so a build config that stops serving an advertised subpath fails the suite — neither publint nor `attw` catches a wildcard whose targets are missing.
|
|
40
|
+
|
|
29
41
|
| Module | Exports |
|
|
30
42
|
|---|---|
|
|
31
43
|
| `zip/container` | `zipPackage` (ordered-entries ZIP write with stored-uncompressed support), `unzipPackage`, `ZipEntry` |
|
|
32
|
-
| `zip/detect` | `detectArchiveFormat` (`'zip' \| 'unknown'`), `isZipArchive`, `ArchiveFormat` |
|
|
44
|
+
| `zip/detect` | `detectArchiveFormat` (`'zip' \| 'cfb' \| 'unknown'`), `isZipArchive`, `ArchiveFormat` |
|
|
33
45
|
| `zip/walk` | `walkArchive` (recursive ZIP-in-ZIP walking), `ArchiveWalkEntry`, `ArchiveWalkLimitError`, `MAX_WALK_DEPTH`, `MAX_WALK_TOTAL_BYTES`, `WalkArchiveOptions` |
|
|
46
|
+
| `cfb/detect` | `isCompoundFile` (the `D0 CF 11 E0 …` magic-byte check) |
|
|
47
|
+
| `cfb/read` | `readCompoundFile` (bounded [MS-CFB] stream extraction), `CompoundFileStream`, `CompoundFileFormatError`, `MAX_CFB_TOTAL_STREAM_BYTES`, `ReadCompoundFileOptions` |
|
|
48
|
+
| `cfb/ole-package` | `readOlePackage` (OLE Package stream unwrapping), `OlePackage`, `OlePackageFormatError` |
|
|
34
49
|
|
|
35
50
|
### Recursive walking
|
|
36
51
|
|
|
@@ -49,6 +64,28 @@ for (const entry of walkArchive(docxBytes)) {
|
|
|
49
64
|
|
|
50
65
|
Both guards throw rather than truncate: an input outside the contract must fail loudly, never return a partial listing that looks complete. The defaults are `MAX_WALK_DEPTH` (8 — real producers bottom out around depth 3; the motivating OOXML embedded-object case is depth 2) and `MAX_WALK_TOTAL_BYTES` (512 MiB cumulative across every nesting level — the same figure `byte-codec` grants a single stream, re-purposed as one shared budget so a bomb's multiplicative nesting leverage becomes bounded addition). Both are overridable per call via `walkArchive(bytes, { maxDepth, maxTotalBytes })`, and each constant's derivation is stated in its source comment.
|
|
51
66
|
|
|
67
|
+
### Compound files
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { readCompoundFile, readOlePackage } from 'archive-codec';
|
|
71
|
+
|
|
72
|
+
// Every stream of a classic OLE compound file, with its storage path.
|
|
73
|
+
// Throws CompoundFileFormatError on any structural nonconformance.
|
|
74
|
+
for (const stream of readCompoundFile(oleBinBytes)) {
|
|
75
|
+
stream.path; // e.g. 'Package' -- root-level, or 'ObjectStorage/Package'
|
|
76
|
+
stream.bytes; // the stream's content
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The OLE packaging a Word/PowerPoint embed wraps the real file in before
|
|
80
|
+
// storing it as the 'Package' stream: label, paths, and the file's bytes.
|
|
81
|
+
const packageStream = readCompoundFile(oleBinBytes).find((s) => s.path === 'Package');
|
|
82
|
+
if (packageStream !== undefined) {
|
|
83
|
+
readOlePackage(packageStream.bytes).fileBytes; // often a ZIP for a modern embed
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Reading is bounded the same way walking is: chain cycles and out-of-range sectors fail against bounds derived from the file's own sector count, and one cumulative extracted-bytes budget (`MAX_CFB_TOTAL_STREAM_BYTES`, 512 MiB — the same figure the family grants one decompressed stream) bounds the multiplication a hostile FAT gains by aliasing one sector into many streams. Every structural failure throws rather than truncating — a malformed compound file fails whole, never a partial stream listing that looks complete. Version 3 (512-byte sectors) and version 4 (4096-byte) files both read; the mini-FAT path every stream shorter than the header's cutoff takes is first-class, because a small real-world embed genuinely lands there.
|
|
88
|
+
|
|
52
89
|
### ZIP container
|
|
53
90
|
|
|
54
91
|
`zipPackage` takes an *ordered* array of `[path, entry]` tuples, not a `Record`, so the caller controls the exact emission order deterministically (the property formats with a fixed-offset first entry — ODF's `mimetype` — depend on), and any entry can be written stored-uncompressed via `stored: true`. `unzipPackage` is the read side; the returned `Record` makes no ordering promise and collapses duplicate paths.
|
|
@@ -57,7 +94,7 @@ Both guards throw rather than truncate: an input outside the contract must fail
|
|
|
57
94
|
|
|
58
95
|
- Worker-isomorphic (see the [family-wide convention](../../README.md#conventions)): runtime `src/` must not import `node:*`, a bare Node builtin, or use the `Buffer` global — enforced by a `no-restricted-imports`/`no-restricted-globals` ESLint rule and exercised in CI by running the test suite inside an actual `workerd` isolate (`pnpm test:workers`). Test files under `src/**/*.test.ts` and `src/test-support/` are exempt and may use Node APIs for fixtures.
|
|
59
96
|
- Only `src/index.ts` may be named `index.*` — a custom ESLint rule (`local/no-non-barrel-index`) rejects any other module using an `index` basename, since that would be a hidden entry point the `exports` map in `package.json` doesn't advertise.
|
|
60
|
-
- Zero document-format knowledge: this package knows bytes and ZIP
|
|
97
|
+
- Zero document-format knowledge: this package knows bytes and container structure — ZIP entries, compound-file sectors and directory entries, the OLE packaging wrapper — never that any entry or stream is a document. It depends only on `fflate` — not on `byte-codec`, `ooxml.js`, or `odf.js` (whose ZIP wrappers it deliberately mirrors rather than imports, keeping their branding and release cadences decoupled).
|
|
61
98
|
|
|
62
99
|
## Install
|
|
63
100
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_magic = require("../magic.cjs");
|
|
3
|
+
//#region src/cfb/detect.ts
|
|
4
|
+
const COMPOUND_FILE_MAGIC = [
|
|
5
|
+
208,
|
|
6
|
+
207,
|
|
7
|
+
17,
|
|
8
|
+
224,
|
|
9
|
+
161,
|
|
10
|
+
177,
|
|
11
|
+
26,
|
|
12
|
+
225
|
|
13
|
+
];
|
|
14
|
+
function isCompoundFile(bytes) {
|
|
15
|
+
return require_magic.startsWithMagic(bytes, COMPOUND_FILE_MAGIC);
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
exports.isCompoundFile = isCompoundFile;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { startsWithMagic } from "../magic.js";
|
|
2
|
+
//#region src/cfb/detect.ts
|
|
3
|
+
const COMPOUND_FILE_MAGIC = [
|
|
4
|
+
208,
|
|
5
|
+
207,
|
|
6
|
+
17,
|
|
7
|
+
224,
|
|
8
|
+
161,
|
|
9
|
+
177,
|
|
10
|
+
26,
|
|
11
|
+
225
|
|
12
|
+
];
|
|
13
|
+
function isCompoundFile(bytes) {
|
|
14
|
+
return startsWithMagic(bytes, COMPOUND_FILE_MAGIC);
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
export { isCompoundFile };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/cfb/ole-package.ts
|
|
3
|
+
var OlePackageFormatError = class extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "OlePackageFormatError";
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
const ANSI_DECODER = new TextDecoder("windows-1252");
|
|
10
|
+
function readZeroTerminated(bytes, offset, fieldName) {
|
|
11
|
+
let end = offset;
|
|
12
|
+
while (end < bytes.length && bytes[end] !== 0) end++;
|
|
13
|
+
if (end >= bytes.length) throw new OlePackageFormatError(`Package stream ends inside its ${fieldName} string with no terminator`);
|
|
14
|
+
return {
|
|
15
|
+
value: ANSI_DECODER.decode(bytes.subarray(offset, end)),
|
|
16
|
+
next: end + 1
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function readOlePackage(bytes) {
|
|
20
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
21
|
+
let offset = 2;
|
|
22
|
+
const label = readZeroTerminated(bytes, offset, "label");
|
|
23
|
+
offset = label.next;
|
|
24
|
+
const sourcePath = readZeroTerminated(bytes, offset, "source path");
|
|
25
|
+
offset = sourcePath.next;
|
|
26
|
+
offset += 8;
|
|
27
|
+
const tempPath = readZeroTerminated(bytes, offset, "temp path");
|
|
28
|
+
offset = tempPath.next;
|
|
29
|
+
if (offset + 4 > bytes.length) throw new OlePackageFormatError("Package stream ends before its packaged file size field");
|
|
30
|
+
const fileByteCount = view.getUint32(offset, true);
|
|
31
|
+
offset += 4;
|
|
32
|
+
if (offset + fileByteCount > bytes.length) throw new OlePackageFormatError(`Package stream declares ${fileByteCount} packaged-file bytes but holds only ${bytes.length - offset}`);
|
|
33
|
+
return {
|
|
34
|
+
label: label.value,
|
|
35
|
+
sourcePath: sourcePath.value,
|
|
36
|
+
tempPath: tempPath.value,
|
|
37
|
+
fileBytes: bytes.slice(offset, offset + fileByteCount)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
exports.OlePackageFormatError = OlePackageFormatError;
|
|
42
|
+
exports.readOlePackage = readOlePackage;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region src/cfb/ole-package.d.ts
|
|
2
|
+
declare class OlePackageFormatError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
interface OlePackage {
|
|
6
|
+
readonly label: string;
|
|
7
|
+
readonly sourcePath: string;
|
|
8
|
+
readonly tempPath: string;
|
|
9
|
+
readonly fileBytes: Uint8Array<ArrayBuffer>;
|
|
10
|
+
}
|
|
11
|
+
declare function readOlePackage(bytes: Uint8Array<ArrayBuffer>): OlePackage;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { OlePackage, OlePackageFormatError, readOlePackage };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region src/cfb/ole-package.d.ts
|
|
2
|
+
declare class OlePackageFormatError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
interface OlePackage {
|
|
6
|
+
readonly label: string;
|
|
7
|
+
readonly sourcePath: string;
|
|
8
|
+
readonly tempPath: string;
|
|
9
|
+
readonly fileBytes: Uint8Array<ArrayBuffer>;
|
|
10
|
+
}
|
|
11
|
+
declare function readOlePackage(bytes: Uint8Array<ArrayBuffer>): OlePackage;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { OlePackage, OlePackageFormatError, readOlePackage };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//#region src/cfb/ole-package.ts
|
|
2
|
+
var OlePackageFormatError = class extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "OlePackageFormatError";
|
|
6
|
+
}
|
|
7
|
+
};
|
|
8
|
+
const ANSI_DECODER = new TextDecoder("windows-1252");
|
|
9
|
+
function readZeroTerminated(bytes, offset, fieldName) {
|
|
10
|
+
let end = offset;
|
|
11
|
+
while (end < bytes.length && bytes[end] !== 0) end++;
|
|
12
|
+
if (end >= bytes.length) throw new OlePackageFormatError(`Package stream ends inside its ${fieldName} string with no terminator`);
|
|
13
|
+
return {
|
|
14
|
+
value: ANSI_DECODER.decode(bytes.subarray(offset, end)),
|
|
15
|
+
next: end + 1
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function readOlePackage(bytes) {
|
|
19
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
20
|
+
let offset = 2;
|
|
21
|
+
const label = readZeroTerminated(bytes, offset, "label");
|
|
22
|
+
offset = label.next;
|
|
23
|
+
const sourcePath = readZeroTerminated(bytes, offset, "source path");
|
|
24
|
+
offset = sourcePath.next;
|
|
25
|
+
offset += 8;
|
|
26
|
+
const tempPath = readZeroTerminated(bytes, offset, "temp path");
|
|
27
|
+
offset = tempPath.next;
|
|
28
|
+
if (offset + 4 > bytes.length) throw new OlePackageFormatError("Package stream ends before its packaged file size field");
|
|
29
|
+
const fileByteCount = view.getUint32(offset, true);
|
|
30
|
+
offset += 4;
|
|
31
|
+
if (offset + fileByteCount > bytes.length) throw new OlePackageFormatError(`Package stream declares ${fileByteCount} packaged-file bytes but holds only ${bytes.length - offset}`);
|
|
32
|
+
return {
|
|
33
|
+
label: label.value,
|
|
34
|
+
sourcePath: sourcePath.value,
|
|
35
|
+
tempPath: tempPath.value,
|
|
36
|
+
fileBytes: bytes.slice(offset, offset + fileByteCount)
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
export { OlePackageFormatError, readOlePackage };
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_cfb_detect = require("./detect.cjs");
|
|
3
|
+
//#region src/cfb/read.ts
|
|
4
|
+
const ENDOFCHAIN = 4294967294;
|
|
5
|
+
const FREESECT = 4294967295;
|
|
6
|
+
const FATSECT = 4294967293;
|
|
7
|
+
const DIFSECT = 4294967292;
|
|
8
|
+
const NOSTREAM = 4294967295;
|
|
9
|
+
const HEADER_SIZE = 512;
|
|
10
|
+
const HEADER_DIFAT_ENTRIES = 109;
|
|
11
|
+
const OBJECT_TYPE_STORAGE = 1;
|
|
12
|
+
const OBJECT_TYPE_STREAM = 2;
|
|
13
|
+
const OBJECT_TYPE_ROOT = 5;
|
|
14
|
+
const MAX_CFB_TOTAL_STREAM_BYTES = 536870912;
|
|
15
|
+
var CompoundFileFormatError = class extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "CompoundFileFormatError";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
function u32(view, offset) {
|
|
22
|
+
return view.getUint32(offset, true);
|
|
23
|
+
}
|
|
24
|
+
function u16(view, offset) {
|
|
25
|
+
return view.getUint16(offset, true);
|
|
26
|
+
}
|
|
27
|
+
function readCompoundFile(bytes, options = {}) {
|
|
28
|
+
const maxTotalBytes = options.maxTotalBytes ?? 536870912;
|
|
29
|
+
if (!require_cfb_detect.isCompoundFile(bytes)) throw new CompoundFileFormatError("readCompoundFile input does not carry the compound-file signature (leading magic bytes are not D0 CF 11 E0 A1 B1 1A E1)");
|
|
30
|
+
if (bytes.length < HEADER_SIZE) throw new CompoundFileFormatError(`compound file is ${bytes.length} bytes, shorter than the fixed ${HEADER_SIZE}-byte header`);
|
|
31
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
32
|
+
const majorVersion = u16(view, 26);
|
|
33
|
+
if (majorVersion !== 3 && majorVersion !== 4) throw new CompoundFileFormatError(`compound file major version ${majorVersion} is not 3 or 4`);
|
|
34
|
+
if (u16(view, 28) !== 65534) throw new CompoundFileFormatError("compound file byte order is not little-endian");
|
|
35
|
+
const sectorShift = u16(view, 30);
|
|
36
|
+
if (majorVersion === 3 && sectorShift !== 9 || majorVersion === 4 && sectorShift !== 12) throw new CompoundFileFormatError(`compound file sector shift 2^${sectorShift} does not match major version ${majorVersion} (version 3 requires 512-byte sectors, version 4 requires 4096-byte)`);
|
|
37
|
+
const miniSectorShift = u16(view, 32);
|
|
38
|
+
if (miniSectorShift !== 6) throw new CompoundFileFormatError(`compound file mini sector shift 2^${miniSectorShift} is not the mandated 64-byte mini sector`);
|
|
39
|
+
const sectorSize = 1 << sectorShift;
|
|
40
|
+
const miniSectorSize = 1 << miniSectorShift;
|
|
41
|
+
const miniStreamCutoff = u32(view, 56);
|
|
42
|
+
if (miniStreamCutoff < miniSectorSize) throw new CompoundFileFormatError(`compound file mini stream cutoff ${miniStreamCutoff} is smaller than the ${miniSectorSize}-byte mini sector itself`);
|
|
43
|
+
const firstDirectorySector = u32(view, 48);
|
|
44
|
+
const firstMiniFatSector = u32(view, 60);
|
|
45
|
+
const firstDifatSector = u32(view, 68);
|
|
46
|
+
const sectorCount = Math.floor(bytes.length / sectorSize) - 1;
|
|
47
|
+
if (sectorCount < 1) throw new CompoundFileFormatError(`compound file holds no complete ${sectorSize}-byte sector after its header`);
|
|
48
|
+
const sectorOffset = (sector) => (sector + 1) * sectorSize;
|
|
49
|
+
const sectorBytes = (sector) => bytes.subarray(sectorOffset(sector), sectorOffset(sector) + sectorSize);
|
|
50
|
+
const fatSectorIds = [];
|
|
51
|
+
const acceptFatSector = (sector, provenance) => {
|
|
52
|
+
if (sector === FREESECT) return;
|
|
53
|
+
if (sector >= sectorCount) throw new CompoundFileFormatError(`${provenance} names FAT sector ${sector}, which is outside the file's ${sectorCount} sectors`);
|
|
54
|
+
fatSectorIds.push(sector);
|
|
55
|
+
};
|
|
56
|
+
for (let i = 0; i < HEADER_DIFAT_ENTRIES; i++) acceptFatSector(u32(view, 76 + i * 4), "the header DIFAT array");
|
|
57
|
+
let difatSector = firstDifatSector;
|
|
58
|
+
let difatSectorsWalked = 0;
|
|
59
|
+
while (difatSector !== ENDOFCHAIN) {
|
|
60
|
+
if (difatSector >= sectorCount) throw new CompoundFileFormatError(`the DIFAT chain names sector ${difatSector}, which is outside the file's ${sectorCount} sectors`);
|
|
61
|
+
if (++difatSectorsWalked > sectorCount) throw new CompoundFileFormatError("the DIFAT chain visits more sectors than the file holds, so it must cycle");
|
|
62
|
+
const difatView = new DataView(bytes.buffer, bytes.byteOffset + sectorOffset(difatSector), sectorSize);
|
|
63
|
+
const entriesPerDifatSector = sectorSize / 4 - 1;
|
|
64
|
+
for (let i = 0; i < entriesPerDifatSector; i++) acceptFatSector(u32(difatView, i * 4), "a DIFAT sector");
|
|
65
|
+
difatSector = u32(difatView, entriesPerDifatSector * 4);
|
|
66
|
+
}
|
|
67
|
+
if (fatSectorIds.length === 0) throw new CompoundFileFormatError("compound file declares no FAT sectors, so no sector chain can be walked");
|
|
68
|
+
const fatBytes = new Uint8Array(fatSectorIds.length * sectorSize);
|
|
69
|
+
for (let i = 0; i < fatSectorIds.length; i++) fatBytes.set(sectorBytes(fatSectorIds[i] ?? 0), i * sectorSize);
|
|
70
|
+
const fat = new DataView(fatBytes.buffer);
|
|
71
|
+
const fatEntry = (sector) => {
|
|
72
|
+
const offset = sector * 4;
|
|
73
|
+
if (offset < 0 || offset + 4 > fatBytes.length) throw new CompoundFileFormatError(`FAT entry for sector ${sector} lies beyond the sectors the DIFAT named`);
|
|
74
|
+
return fat.getUint32(offset, true);
|
|
75
|
+
};
|
|
76
|
+
const chainSectorIds = (start) => {
|
|
77
|
+
const ids = [];
|
|
78
|
+
let current = start;
|
|
79
|
+
while (current !== ENDOFCHAIN) {
|
|
80
|
+
if (current >= sectorCount) throw new CompoundFileFormatError(`a FAT chain steps to sector ${current}, which is outside the file's ${sectorCount} sectors`);
|
|
81
|
+
if (ids.length >= sectorCount) throw new CompoundFileFormatError("a FAT chain visits more sectors than the file holds, so it must cycle");
|
|
82
|
+
ids.push(current);
|
|
83
|
+
const next = fatEntry(current);
|
|
84
|
+
if (next === FREESECT || next === FATSECT || next === DIFSECT) throw new CompoundFileFormatError(`a FAT chain steps to sector ${current}'s entry ${next}, which is a sector-role marker, not a chain continuation`);
|
|
85
|
+
current = next;
|
|
86
|
+
}
|
|
87
|
+
return ids;
|
|
88
|
+
};
|
|
89
|
+
const chainBytes = (start) => {
|
|
90
|
+
const ids = chainSectorIds(start);
|
|
91
|
+
const out = new Uint8Array(ids.length * sectorSize);
|
|
92
|
+
for (let i = 0; i < ids.length; i++) out.set(sectorBytes(ids[i] ?? 0), i * sectorSize);
|
|
93
|
+
return out;
|
|
94
|
+
};
|
|
95
|
+
const directoryBytes = chainBytes(firstDirectorySector);
|
|
96
|
+
if (directoryBytes.length === 0) throw new CompoundFileFormatError("compound file has an empty directory chain");
|
|
97
|
+
const directoryView = new DataView(directoryBytes.buffer);
|
|
98
|
+
const entryCount = directoryBytes.length / 128;
|
|
99
|
+
const nameDecoder = new TextDecoder("utf-16le");
|
|
100
|
+
const entries = [];
|
|
101
|
+
for (let id = 0; id < entryCount; id++) {
|
|
102
|
+
const base = id * 128;
|
|
103
|
+
const nameLength = u16(directoryView, base + 64);
|
|
104
|
+
entries.push({
|
|
105
|
+
name: nameDecoder.decode(directoryBytes.subarray(base, base + Math.max(0, nameLength - 2))),
|
|
106
|
+
nameLength,
|
|
107
|
+
objectType: directoryView.getUint8(base + 66),
|
|
108
|
+
leftSibling: u32(directoryView, base + 68),
|
|
109
|
+
rightSibling: u32(directoryView, base + 72),
|
|
110
|
+
child: u32(directoryView, base + 76),
|
|
111
|
+
startSector: u32(directoryView, base + 116),
|
|
112
|
+
size: u32(directoryView, base + 120) + u32(directoryView, base + 124) * 4294967296
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const root = entries[0];
|
|
116
|
+
if (root === void 0 || root.objectType !== OBJECT_TYPE_ROOT) throw new CompoundFileFormatError("the first directory entry is not the root storage entry (object type 5), as [MS-CFB] 2.6.1 requires");
|
|
117
|
+
const miniStream = chainBytes(root.startSector).subarray(0, root.size);
|
|
118
|
+
const miniSectorCount = Math.floor(miniStream.length / miniSectorSize);
|
|
119
|
+
const miniFatBytes = chainBytes(firstMiniFatSector);
|
|
120
|
+
const miniFat = new DataView(miniFatBytes.buffer);
|
|
121
|
+
const miniChainSectorIds = (start) => {
|
|
122
|
+
const ids = [];
|
|
123
|
+
let current = start;
|
|
124
|
+
while (current !== ENDOFCHAIN) {
|
|
125
|
+
if (current >= miniSectorCount) throw new CompoundFileFormatError(`a mini-FAT chain steps to mini sector ${current}, which is outside the mini stream's ${miniSectorCount} mini sectors`);
|
|
126
|
+
if (ids.length >= miniSectorCount) throw new CompoundFileFormatError("a mini-FAT chain visits more mini sectors than the mini stream holds, so it must cycle");
|
|
127
|
+
ids.push(current);
|
|
128
|
+
const next = miniFat.getUint32(current * 4, true);
|
|
129
|
+
if (next === FREESECT || next === FATSECT || next === DIFSECT) throw new CompoundFileFormatError(`a mini-FAT chain steps to mini sector ${current}'s entry ${next}, which is a sector-role marker, not a chain continuation`);
|
|
130
|
+
current = next;
|
|
131
|
+
}
|
|
132
|
+
return ids;
|
|
133
|
+
};
|
|
134
|
+
let totalExtractedBytes = 0;
|
|
135
|
+
const extractStream = (entry) => {
|
|
136
|
+
if (entry.size === 0) return /* @__PURE__ */ new Uint8Array(0);
|
|
137
|
+
if (entry.size > Number.MAX_SAFE_INTEGER) throw new CompoundFileFormatError(`stream '${entry.name}' declares a size beyond the integer range this reader addresses`);
|
|
138
|
+
let out;
|
|
139
|
+
if (entry.size < miniStreamCutoff) {
|
|
140
|
+
const ids = miniChainSectorIds(entry.startSector);
|
|
141
|
+
const raw = new Uint8Array(ids.length * miniSectorSize);
|
|
142
|
+
for (let i = 0; i < ids.length; i++) {
|
|
143
|
+
const miniSector = ids[i] ?? 0;
|
|
144
|
+
raw.set(miniStream.subarray(miniSector * miniSectorSize, (miniSector + 1) * miniSectorSize), i * miniSectorSize);
|
|
145
|
+
}
|
|
146
|
+
out = raw;
|
|
147
|
+
} else out = chainBytes(entry.startSector);
|
|
148
|
+
if (out.length < entry.size) throw new CompoundFileFormatError(`stream '${entry.name}' declares ${entry.size} bytes but its chain holds only ${out.length}`);
|
|
149
|
+
totalExtractedBytes += entry.size;
|
|
150
|
+
if (totalExtractedBytes > maxTotalBytes) throw new CompoundFileFormatError(`cumulative extracted stream size exceeded the ${maxTotalBytes}-byte budget at '${entry.name}'`);
|
|
151
|
+
return out.slice(0, entry.size);
|
|
152
|
+
};
|
|
153
|
+
const streams = [];
|
|
154
|
+
const visited = /* @__PURE__ */ new Set();
|
|
155
|
+
const stack = [{
|
|
156
|
+
id: root.child,
|
|
157
|
+
prefix: "",
|
|
158
|
+
stage: "descend"
|
|
159
|
+
}];
|
|
160
|
+
for (let frame = stack.pop(); frame !== void 0; frame = stack.pop()) if (frame.stage === "descend") {
|
|
161
|
+
const { id, prefix } = frame;
|
|
162
|
+
if (id === NOSTREAM) continue;
|
|
163
|
+
const entry = entries[id];
|
|
164
|
+
if (id >= entryCount || entry === void 0) throw new CompoundFileFormatError(`the directory tree links to entry ${id}, which is outside the directory's ${entryCount} entries`);
|
|
165
|
+
if (visited.has(id)) throw new CompoundFileFormatError(`the directory tree reaches entry ${id} twice, so its sibling and child links cycle`);
|
|
166
|
+
visited.add(id);
|
|
167
|
+
if (entry.nameLength < 2 || entry.nameLength > 64 || entry.nameLength % 2 === 1) throw new CompoundFileFormatError(`directory entry ${id} declares name length ${entry.nameLength}, which is not an even byte count between 2 and 64`);
|
|
168
|
+
if (entry.objectType !== OBJECT_TYPE_STORAGE && entry.objectType !== OBJECT_TYPE_STREAM && entry.objectType !== OBJECT_TYPE_ROOT) throw new CompoundFileFormatError(`directory entry ${id} ('${entry.name}') carries object type ${entry.objectType}, which is not a storage (1), stream (2), or root (5) entry`);
|
|
169
|
+
stack.push({
|
|
170
|
+
entry,
|
|
171
|
+
prefix,
|
|
172
|
+
stage: "self"
|
|
173
|
+
});
|
|
174
|
+
stack.push({
|
|
175
|
+
id: entry.leftSibling,
|
|
176
|
+
prefix,
|
|
177
|
+
stage: "descend"
|
|
178
|
+
});
|
|
179
|
+
} else if (frame.entry.objectType === OBJECT_TYPE_STREAM) {
|
|
180
|
+
streams.push({
|
|
181
|
+
path: frame.prefix + frame.entry.name,
|
|
182
|
+
bytes: extractStream(frame.entry)
|
|
183
|
+
});
|
|
184
|
+
stack.push({
|
|
185
|
+
id: frame.entry.rightSibling,
|
|
186
|
+
prefix: frame.prefix,
|
|
187
|
+
stage: "descend"
|
|
188
|
+
});
|
|
189
|
+
} else if (frame.entry.objectType === OBJECT_TYPE_STORAGE) {
|
|
190
|
+
stack.push({
|
|
191
|
+
id: frame.entry.rightSibling,
|
|
192
|
+
prefix: frame.prefix,
|
|
193
|
+
stage: "descend"
|
|
194
|
+
});
|
|
195
|
+
stack.push({
|
|
196
|
+
id: frame.entry.child,
|
|
197
|
+
prefix: `${frame.prefix}${frame.entry.name}/`,
|
|
198
|
+
stage: "descend"
|
|
199
|
+
});
|
|
200
|
+
} else throw new CompoundFileFormatError(`the directory tree reaches entry ${frame.entry.name}, which is not a storage or stream entry`);
|
|
201
|
+
return streams;
|
|
202
|
+
}
|
|
203
|
+
//#endregion
|
|
204
|
+
exports.CompoundFileFormatError = CompoundFileFormatError;
|
|
205
|
+
exports.MAX_CFB_TOTAL_STREAM_BYTES = MAX_CFB_TOTAL_STREAM_BYTES;
|
|
206
|
+
exports.readCompoundFile = readCompoundFile;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/cfb/read.d.ts
|
|
2
|
+
declare const MAX_CFB_TOTAL_STREAM_BYTES: number;
|
|
3
|
+
declare class CompoundFileFormatError extends Error {
|
|
4
|
+
constructor(message: string);
|
|
5
|
+
}
|
|
6
|
+
interface CompoundFileStream {
|
|
7
|
+
readonly path: string;
|
|
8
|
+
readonly bytes: Uint8Array<ArrayBuffer>;
|
|
9
|
+
}
|
|
10
|
+
interface ReadCompoundFileOptions {
|
|
11
|
+
readonly maxTotalBytes?: number;
|
|
12
|
+
}
|
|
13
|
+
declare function readCompoundFile(bytes: Uint8Array<ArrayBuffer>, options?: ReadCompoundFileOptions): CompoundFileStream[];
|
|
14
|
+
//#endregion
|
|
15
|
+
export { CompoundFileFormatError, CompoundFileStream, MAX_CFB_TOTAL_STREAM_BYTES, ReadCompoundFileOptions, readCompoundFile };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/cfb/read.d.ts
|
|
2
|
+
declare const MAX_CFB_TOTAL_STREAM_BYTES: number;
|
|
3
|
+
declare class CompoundFileFormatError extends Error {
|
|
4
|
+
constructor(message: string);
|
|
5
|
+
}
|
|
6
|
+
interface CompoundFileStream {
|
|
7
|
+
readonly path: string;
|
|
8
|
+
readonly bytes: Uint8Array<ArrayBuffer>;
|
|
9
|
+
}
|
|
10
|
+
interface ReadCompoundFileOptions {
|
|
11
|
+
readonly maxTotalBytes?: number;
|
|
12
|
+
}
|
|
13
|
+
declare function readCompoundFile(bytes: Uint8Array<ArrayBuffer>, options?: ReadCompoundFileOptions): CompoundFileStream[];
|
|
14
|
+
//#endregion
|
|
15
|
+
export { CompoundFileFormatError, CompoundFileStream, MAX_CFB_TOTAL_STREAM_BYTES, ReadCompoundFileOptions, readCompoundFile };
|