mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
The base is FilmCraft's filmcraft-cfb (reader, writer, tests) at filmcraft 6d5269ed. Ported on top of it, each with a test: From GridCraft's and DeckCraft's readers: only sector shifts 9 and 12; a file that ends inside its last sector still reads; a chain loop shorter than the stream is an error instead of repeated data; a damaged mini stream no longer fails the open; links to missing directory entries are ignored; a stream size cap (Limits, default 1 GiB, and a per-call cap); a lenient directory scan (directory_names, has_entry_named) for files too damaged to open. From WordCraft's guard: a FAT sector listed twice is refused; spare FAT sectors and zero-padded DIFAT sectors are accepted. New: Limits::max_dir_entries, walk() and path() to list entries, has_stream, Writer::stream_at and write_streams, and only storages the root reaches are walked.
191 lines
12 KiB
Markdown
191 lines
12 KiB
Markdown
# craft-cfb
|
|
|
|
A clean-room reader and writer for the Compound File Binary format (Microsoft "OLE structured
|
|
storage"): the container of legacy Office files (`.doc`, `.xls`, `.ppt`), Outlook `.msg` messages,
|
|
password-protected OOXML packages and AAF. No dependencies beyond `std`, no `unsafe`. No
|
|
third-party implementation was consulted.
|
|
|
|
| Document | Edition | Used for |
|
|
|---|---|---|
|
|
| [MS-CFB] Compound File Binary File Format (Microsoft Open Specifications) | revision 10.0 (the format is unchanged since 1.0) | header, sector chains, FAT / DIFAT / mini FAT, directory entries, red-black sibling trees, name comparison, version 3 and 4 files |
|
|
|
|
The crate knows nothing about what the streams hold. Property sets, Office records, encryption
|
|
and AAF objects belong to the crates that read those formats.
|
|
|
|
## Reading
|
|
|
|
```rust
|
|
let bytes = std::fs::read("book.xls")?;
|
|
let cf = craft_cfb::CompoundFile::open(&bytes)?;
|
|
let workbook = cf.read_path("Workbook")?; // a stream, by path
|
|
let small = cf.read_path_capped("\u{5}SummaryInformation", 1 << 20)?; // with a size cap
|
|
for id in cf.walk() { // every storage and stream
|
|
let e = &cf.entries[id];
|
|
println!("{:?} {} {}", e.kind, cf.path(id).unwrap_or_default(), e.size);
|
|
}
|
|
let vba = cf.find("Macros/VBA")?; // a storage
|
|
for &child in &cf.entries[vba].children { /* its children, in sibling order */ }
|
|
```
|
|
|
|
- Input is a `&[u8]`. Streams are copied out on `read`; nothing is copied before that except the
|
|
FAT, the mini FAT, the directory and the mini stream.
|
|
- Version 3 (512-byte sectors) and version 4 (4096-byte sectors).
|
|
- Paths are `/`-separated below the root; a leading `/` is allowed. Names compare as [MS-CFB]
|
|
§2.6.4 says siblings do: case-insensitively, by upper-cased UTF-16 code units
|
|
(`compare_names`).
|
|
- Lookups do not depend on the sibling trees being sorted, balanced or correctly coloured. Each
|
|
storage's children are indexed by name when the file is opened.
|
|
|
|
### What the reader tolerates
|
|
|
|
- A file that ends inside its last sector (some writers do not pad it). The sector reads short;
|
|
a stream that needs bytes the file does not hold is still an error, and a short sector in the
|
|
middle of a chain is an error.
|
|
- A mini stream that is damaged or shorter than the root entry says. The file opens and streams in
|
|
regular sectors read; only the streams that need the missing part fail.
|
|
- Sibling and child links to directory entries that do not exist. They are treated as no link.
|
|
- Directory entries the root does not reach, whatever they point at.
|
|
- Spare FAT sectors (writers reserve them for growth) and DIFAT sectors padded with zeros.
|
|
- Garbage in the high half of a stream size before version 4.
|
|
- A version field that does not match the sector size (the sector shift decides).
|
|
|
|
### What the reader refuses
|
|
|
|
`Error::NotCfb` for a missing signature or byte order mark, `Error::Invalid` for the rest:
|
|
|
|
- a sector shift other than 9 or 12, a mini sector shift other than 6;
|
|
- more FAT sectors than the file has sectors, or a FAT sector listed twice;
|
|
- a DIFAT, FAT or mini FAT chain that loops or leaves the file, including a loop shorter than the
|
|
stream it belongs to;
|
|
- a directory entry reached twice (a cycle or a shared subtree);
|
|
- a first directory entry that is not the root.
|
|
|
|
### Limits
|
|
|
|
Every size, offset and chain from the file is checked, and every loop is bounded by the number of
|
|
sectors the file has. `open` allocates the FAT, the mini FAT, the directory and the mini stream,
|
|
each of which is made of distinct sectors of the input, so its memory is a small multiple of the
|
|
input's length. Two things are capped by `Limits` (`CompoundFile::open_with`):
|
|
|
|
| Limit | Default | Exceeded |
|
|
|---|---|---|
|
|
| `max_stream_size` | 1 GiB | `read` returns `Error::LimitExceeded`; nothing is allocated |
|
|
| `max_dir_entries` | 1 048 576 | `open` returns `Error::LimitExceeded` |
|
|
|
|
`read_capped` and `read_path_capped` take a smaller cap for one call. A stream whose declared
|
|
size is larger than the file is refused before any allocation, whatever the limits.
|
|
|
|
### Files too damaged to open
|
|
|
|
```rust
|
|
let limits = craft_cfb::Limits::default();
|
|
let locked = ["EncryptedPackage", "EncryptionInfo"].iter().any(|n| craft_cfb::has_entry_named(&bytes, n, &limits));
|
|
```
|
|
|
|
`directory_names` follows only the header, the DIFAT and the directory chain and returns the name
|
|
of every directory entry. `has_entry_named` uses it and, when the directory cannot be followed,
|
|
searches the bytes for the name in UTF-16LE. It is meant for choosing an error message ("this
|
|
file is password-protected"), not for security decisions: the fallback can match stream contents.
|
|
|
|
## Writing
|
|
|
|
```rust
|
|
let file = craft_cfb::write_streams(craft_cfb::Version::V3, &[("Workbook", &book), ("Macros/VBA/dir", &dir)])?;
|
|
|
|
let mut w = craft_cfb::Writer::new(craft_cfb::Version::V4);
|
|
w.set_clsid(craft_cfb::Writer::ROOT, root_class);
|
|
let h = w.storage(craft_cfb::Writer::ROOT, "Header", header_class)?;
|
|
w.stream(h, "properties", bytes)?;
|
|
w.stream_at("Header/Content/data", more)?; // creates "Content"
|
|
let file: Vec<u8> = w.finish();
|
|
```
|
|
|
|
Layout: stream sectors (streams of 4096 bytes or more), mini stream, mini FAT, directory, FAT,
|
|
DIFAT. Each chain is contiguous. Sibling trees are balanced binary search trees in [MS-CFB] name
|
|
order (length first, then upper-cased code units) coloured as valid red-black trees. Names are
|
|
checked (1 to 31 UTF-16 code units, none of `/ \ : !` or NUL, unique per storage,
|
|
case-insensitively); the duplicate check is a hash lookup, so adding n children is O(n).
|
|
|
|
## What it does not do
|
|
|
|
- No `Read + Seek` input and no streaming output: files are read from and written to memory.
|
|
- No editing in place. To change a file, read its streams and write a new one.
|
|
- No timestamps or state bits on write (they are read).
|
|
- No transactions, no sector-level access, no free-sector reuse.
|
|
- Version 3 files are not limited to the 2 GB the specification allows them.
|
|
- Nothing about stream contents (property sets, `\1CompObj`, encryption).
|
|
|
|
## Where it came from
|
|
|
|
The base is FilmCraft's `filmcraft-cfb` (reader, writer and tests, moved as they were). Three
|
|
other Craft apps had their own readers; what they did and the base did not was ported, each with
|
|
a test in `src/tests/ported.rs`. Where the copies disagreed:
|
|
|
|
| Question | FilmCraft | GridCraft / DeckCraft | WordCraft (guard in front of the `cfb` crate) | Here |
|
|
|---|---|---|---|---|
|
|
| Sector shift | 7 to 16 | 9 or 12 | by version: 3 → 512, 4 → 4096 | 9 or 12 (the specification; a smaller sector overlaps the header) |
|
|
| Byte order mark | required | not checked | checked by `cfb` | required by `open`; not checked by `directory_names` |
|
|
| Where the FAT sector list ends | the header's count | every listed number | the header's count, trailing zeros and free slots trimmed | the header's count: it is what makes zero-padded DIFAT sectors readable |
|
|
| A FAT sector listed twice | allowed (FAT bounded by the header's count) | allowed | refused | refused |
|
|
| More FAT sectors than the file has sectors | refused | refused | refused; a tighter bound had refused real files with spare FAT sectors | refused; spare sectors accepted |
|
|
| File ends inside its last sector | error | reads short, if it is the chain's last sector | (left to `cfb`) | GridCraft's rule, for streams, the directory, the mini FAT and the mini stream; FAT and DIFAT sectors must be whole |
|
|
| Chain loop | step count bounded by the file's sectors; a loop inside the stream's size read back as repeated data | a visited flag per FAT entry, allocated per read | (left to `cfb`) | step bound, plus a check for a repeated sector in the chain (memory proportional to the stream, not to the FAT) |
|
|
| Damaged or short mini stream | `open` fails | `open` succeeds, the stream read fails | (left to `cfb`) | `open` succeeds |
|
|
| Stream size cap | the file's length | 1 GiB | a per-call cap at each caller | `Limits::max_stream_size` (default 1 GiB) and a per-call cap |
|
|
| Size before version 4 | high half ignored when the version is 3 | high half ignored when the version is below 4 | (left to `cfb`) | below 4 |
|
|
| Link to a missing directory entry | `open` fails | ignored | (left to `cfb`) | ignored |
|
|
| Entry reached twice | `open` fails | the lookup fails if it gets that far | (left to `cfb`) | `open` fails |
|
|
| Which storages are walked | every storage entry, in directory order (an orphan pointing into the live tree made `open` fail) | the root only | (left to `cfb`) | every storage the root reaches |
|
|
| Name lookup | [MS-CFB] upper-casing, sorted index | ASCII case-insensitive, linear, streams only | `cfb` | FilmCraft's; `has_stream` for "a stream of that name" |
|
|
| Name of an entry | the code units the length covers, up to a NUL | the code units the length covers | `cfb` | FilmCraft's |
|
|
| Directory size | the file's length | 1024 sectors in the scan | (left to `cfb`) | `Limits::max_dir_entries` |
|
|
| Largest regular sector number | `<= 0xFFFFFFFA` | `< 0xFFFFFFFA` | `cfb` | `<= 0xFFFFFFFA` (no file can hold either) |
|
|
|
|
## Migration
|
|
|
|
FilmCraft (`filmcraft_cfb`):
|
|
|
|
| Before | After |
|
|
|---|---|
|
|
| `filmcraft_cfb::{CompoundFile, Entry, EntryKind, Writer, NodeId, Version, Error, sniff, compare_names, validate_name}` | the same names in `craft_cfb` |
|
|
| `CompoundFile::open(bytes)` with no stream cap | `CompoundFile::open_with(bytes, Limits { max_stream_size: u64::MAX, ..Limits::default() })` if streams over 1 GiB must read |
|
|
| matching on `Error` | `Error` is `#[non_exhaustive]` and has `LimitExceeded` |
|
|
|
|
GridCraft (`crates/xlsx/src/cfb.rs`) and DeckCraft (`crates/pptx/src/cfb.rs`):
|
|
|
|
| Before | After |
|
|
|---|---|
|
|
| `cfb::SIGNATURE`, `bytes.starts_with(&SIGNATURE)` | `craft_cfb::SIGNATURE`, `craft_cfb::sniff(bytes)` |
|
|
| `cfb::is_encrypted(bytes)` | `["EncryptedPackage", "EncryptionInfo"].iter().any(\|n\| craft_cfb::has_entry_named(bytes, n, &limits))` |
|
|
| `directory_names(bytes)` | `craft_cfb::directory_names(bytes, &limits)` |
|
|
| `Compound::open(bytes)` | `CompoundFile::open(bytes)` |
|
|
| `cf.has_root_stream(name)` | `cf.has_stream(name)` |
|
|
| `cf.root_stream(name)` → `Ok(None)` when absent | `if cf.has_stream(name) { Some(cf.read_path(name)?) } else { None }` |
|
|
| `MAX_STREAM` (1 GiB) | the default `Limits::max_stream_size` |
|
|
| `testing::compound_file(streams)` | `craft_cfb::write_streams(Version::V3, streams)` (a different sector layout: tests that cut or patch the file at fixed offsets need new offsets) |
|
|
|
|
WordCraft (`wordcraft_doc::cfb_guard` and the `cfb` crate):
|
|
|
|
| Before | After |
|
|
|---|---|
|
|
| `cfb_guard::check(bytes)?` then `cfb::CompoundFile::open(Cursor::new(bytes))` | `CompoundFile::open(bytes)` (the guard's checks are part of `open`) |
|
|
| `comp.is_stream(path)` | `cf.has_stream(path)` |
|
|
| `comp.open_stream(path)` then `take(cap + 1).read_to_end(..)` | `cf.read_path_capped(path, cap)` (`Error::LimitExceeded` when larger) |
|
|
| `CompoundFile::create_with_version(v, Cursor::new(Vec::new()))`, `create_storage_all`, `create_stream(..).write_all(..)`, `flush`, `into_inner().into_inner()` | `Writer::new(v)`, `stream_at(path, bytes)`, `finish()`, or `write_streams(v, &[(path, bytes)])` |
|
|
| `cfb::Version::{V3, V4}` | `craft_cfb::Version::{V3, V4}` |
|
|
| `cfb_guard::repeating_difat_sample()` (test input) | not exported; the regression is `a_repeating_difat_is_refused` here |
|
|
|
|
## Tests
|
|
|
|
`src/tests.rs` (from FilmCraft): round trips for both versions with empty, mini, boundary
|
|
(63/64/65, 4095/4096/4097 bytes) and multi-sector streams and up to 40 siblings; red-black
|
|
invariants of every sibling tree; a 7.5 MB stream that needs DIFAT sectors; Unicode and
|
|
case-insensitive names; random mutations; DIFAT and directory chain loops.
|
|
|
|
`src/tests/ported.rs`: files from a second, independent test writer with another layout and an
|
|
unsorted sibling list; every behaviour in the table above; each limit; truncation at every length
|
|
and every single-bit flip of two small files, and every special sector value in every header, FAT
|
|
and directory field, none of which may panic or hang.
|
|
|
|
`tests/writer_scale.rs`: 40 000 children in one storage in under 3 s; duplicate-name rules.
|