Skip to content

feat(archive-codec): write conformant [MS-CFB] compound files - #863

Merged
Mearman merged 3 commits into
mainfrom
worktree-agent-a08a61dc31ae0de62
Sep 3, 2026
Merged

feat(archive-codec): write conformant [MS-CFB] compound files#863
Mearman merged 3 commits into
mainfrom
worktree-agent-a08a61dc31ae0de62

Conversation

@Mearman

@Mearman Mearman commented Sep 3, 2026

Copy link
Copy Markdown
Member

archive-codec could read a classic OLE compound file but not produce one, which is what blocks write support in xls-codec (#815), doc-codec (#816), and ppt-codec (#817): a .xls writer emitting a Workbook stream, or a .doc writer emitting WordDocument and 1Table, needs a container to put them in. This adds writeCompoundFile as the mirror of readCompoundFile — it takes the same CompoundFileStream[], so writeCompoundFile(readCompoundFile(bytes)) is a round trip rather than a translation. That symmetry is also why nested storages are supported even though none of the four codecs needs one: the reader emits slash-joined paths, so a writer that refused them couldn't re-write what its own package had just read.

Covers the header, the FAT, the DIFAT in both spellings (the header's 109-entry array and chained DIFAT sectors past it), the directory, and both allocation paths — FAT-chained sectors at or above the 4096-byte cutoff, 64-byte mini sectors below it.

Two things that aren't obvious from the signature

The directory's sibling trees are genuine red-black trees, not the right-sibling chain a purely structural reader would also accept. The tree exists to be binary-searched by name, so a chain isn't a smaller version of it, it's the wrong structure. Splitting each sorted sibling list at its midpoint puts nodes at depths 0..D for D = floor(log2 n) and empty positions no shallower than floor(log2(n+1)), so colouring exactly the depth-D nodes red satisfies every [MS-CFB] 2.6.4 constraint by construction — no rebalancing pass. The tests assert the invariants including black height, rather than taking the argument on trust.

Sibling identity is the format's ordering, not string equality (shorter names first, then simple-uppercased UTF-16 code point), so Table and TABLE are one name and the same comparator decides both the sort and the uniqueness check.

Validation

The round trip through this package's own reader is the bulk of the suite, but that only proves internal consistency, so it was checked against parsers that share no code with it:

  • olefile 0.47 in strict DEFECT_INCORRECT mode accepts every fixture (minimal, empty, mixed mini+FAT, nested storages, 63 siblings, an 8 MiB DIFAT-chained file, version 4) and returns byte-identical stream content. Confirmed non-vacuous: deliberately corrupting the sector shift, mini-sector shift, cutoff, reserved bytes, or a sibling link makes it reject.
  • 7-Zip's Compound handler lists the same streams, sizes, and storages, and its extraction SHA-256s match independently computed expectations — including the 8 MiB stream that spans a DIFAT chain.
  • A real LibreOffice-authored .doc (6 streams, control-prefixed names like \x01CompObj and \x05SummaryInformation) read through readCompoundFile and re-emitted through writeCompoundFile: every stream hash matches, file(1) identifies the result identically to the original, and LibreOffice Writer opens the rewritten file and reads the text back correctly.

Scope and limits

  • Chained DIFAT sectors are written rather than a cap imposed — the header array alone would limit a version 3 file to 6.875 MB, which a real .doc or .xls passes routinely.
  • Version 3 streams past 0x80000000 bytes throw, since the 64-bit size field would need a high half the spec forbids there. This is the one validation branch with no test, because exercising it means allocating 2 GB.
  • The format's MAXREGSECT sector ceiling isn't checked: at ~2 TB it's unreachable inside a single Uint8Array, whose own allocation limit fails loudly first.
  • Output is deterministic in the path set, not the input order — the directory's order is the format's name ordering.

Tests

37 new cases in src/cfb/write.test.ts: header and sector layout asserted byte-for-byte against the spec's field tables (so a wrong offset fails here rather than surviving because our own reader makes the same mistake), round trips across mini/FAT/cutoff-boundary/ragged/zero-length/empty-file/nested/multi-directory-sector/multi-FAT-sector/DIFAT-chain/version-4 cases, red-black invariants parsed out of the directory directly, and the validation failures. Plus barrel, workerd, and smoke coverage for both directions.

lint, typecheck (incl. attw), test, test:workers, and test:smoke all pass, and turbo run _lint _typecheck _test --filter=...archive-codec is green across all 43 tasks, so the four consumers are unregressed.

Infrastructure for #815, #816, and #817 rather than a fix for any one of them, so no closing keyword.

@Mearman
Mearman marked this pull request as ready for review September 3, 2026 09:20
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review Completed 2026-09-03T09:29:15.625384Z b4d756a Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Mearman
Mearman force-pushed the worktree-agent-a08a61dc31ae0de62 branch 2 times, most recently from 8843f58 to eca7409 Compare September 3, 2026 09:38
writeCompoundFile takes the CompoundFileStream array readCompoundFile
returns and emits a compound file from it, so re-writing what was read is
a round trip rather than a translation between two vocabularies.

It writes the header, the FAT, the DIFAT in both its spellings (the
header's own 109-entry array and chained DIFAT sectors past that), the
directory, and both allocation paths: a stream at or above the 4096-byte
cutoff takes FAT-chained sectors, one below it a run of 64-byte mini
sectors in the root entry's mini stream.

Two parts are load-bearing beyond "a reader can parse it".

The directory's sibling trees are genuine red-black trees rather than the
right-sibling chain a purely structural reader would also accept. The
sibling tree exists to be binary-searched by name, so a chain is not a
smaller version of it, it is the wrong data structure. Splitting each
sorted sibling list at its midpoint puts nodes at depths 0..D for
D = floor(log2 n) and empty positions no shallower than floor(log2(n+1)),
so colouring exactly the depth-D nodes red gives every root-to-leaf path
D + 1 black nodes with no two reds adjacent and a black root -- every
[MS-CFB] 2.6.4 constraint by construction, with no rebalancing pass.

Sibling identity is the format's own ordering, not string equality:
shorter names sort first, equal-length names compare by simple-uppercased
UTF-16 code point. That makes 'Table' and 'TABLE' one name, which is why
the same comparator decides both the sort and the uniqueness check.

Chained DIFAT sectors are written rather than a size limit imposed,
because the header array alone caps a version 3 file at 6.875 MB and a
real .doc or .xls passes that routinely. The one ceiling that does throw
is the version 3 per-stream limit of 0x80000000 bytes, past which the
64-bit size field would need a high half the spec forbids there.

Because the directory's order is the format's name ordering rather than
the caller's, the output depends only on the set of paths: two callers
building the same file from differently ordered lists get identical bytes.
Adds cfb/write to the module table, documents what writeCompoundFile
accepts and refuses, and records the two decisions a caller cannot see
from the signature: the sibling trees are real red-black trees because
the tree exists to be binary-searched, and the output is a function of
the path set alone rather than of the order they were supplied in.

Also restates CompoundFileStream's own comment as the package's
compound-file vocabulary in both directions rather than the reader's
alone, since the writer now takes the same shape.
… them

Three places described the package as CFB-read-only, which stopped being
true when writeCompoundFile landed:

- the root package table's archive-codec row;
- COMPARISON.md, where SheetJS's cfb was the closest counterpart partly
  because it wrote and archive-codec did not, which is no longer the
  distinction between them;
- xls-codec's remaining-scope note, which listed a compound-file writer
  among the things #815 still has to build. The container half exists
  now, so what is left there is the BIFF8 record emission.
@Mearman
Mearman force-pushed the worktree-agent-a08a61dc31ae0de62 branch from eca7409 to 360b588 Compare September 3, 2026 09:55
@Mearman
Mearman merged commit b8873e7 into main Sep 3, 2026
16 checks passed
@Mearman
Mearman deleted the worktree-agent-a08a61dc31ae0de62 branch September 3, 2026 09:58
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 1.0.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant