Files
craft-libs/crates/cfb/src/lib.rs
T
Dylan M. Taylor 06af3f2d46 Add craft-cfb: Compound File Binary reader and writer (#818)
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.
2026-11-11 12:49:55 -05:00

132 lines
5.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! Clean-room Compound File Binary format reader and writer ("OLE structured storage": the
//! container of legacy Office files, Outlook `.msg` messages, encrypted OOXML packages and AAF),
//! written from the public Microsoft Open Specification **[MS-CFB]: Compound File Binary File
//! Format** (protocol revision 10.0 and earlier editions; the on-disk format has not changed
//! since 1.0).
//!
//! A compound file is a small FAT file system inside one file: fixed-size sectors (512 bytes in
//! version 3, 4096 bytes in version 4) chained by a file allocation table, a directory of 128-byte
//! entries arranged as one red-black tree of siblings per storage, and a "mini stream" with a mini
//! FAT for streams smaller than 4096 bytes.
//!
//! - [`CompoundFile::open`] parses a file held in memory (no copies of large streams; every chain
//! is bounds- and cycle-checked, so damaged files give errors, never panics or endless loops).
//! [`Limits`] caps the stream size and the number of directory entries.
//! - [`Writer`] builds a tree of storages and streams and lays it out in one pass
//! ([`Writer::finish`]): stream sectors, mini stream, mini FAT, directory, FAT and DIFAT.
//! [`write_streams`] is the one-call form for a list of `(path, bytes)`.
//! - [`directory_names`] and [`has_entry_named`] answer "does this file hold an entry called X"
//! for files too damaged to open.
//!
//! No dependencies beyond `std`, no `unsafe`, nothing platform-specific.
mod read;
mod scan;
mod write;
pub use read::{CompoundFile, Entry, EntryKind, Limits};
pub use scan::{directory_names, has_entry_named};
pub use write::{NodeId, Version, Writer, write_streams};
use std::cmp::Ordering;
use std::fmt;
/// The 8-byte header signature (§2.2).
pub const SIGNATURE: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
/// Largest regular sector number (§2.1).
pub const MAXREGSECT: u32 = 0xFFFF_FFFA;
/// Sector used by the DIFAT.
pub const DIFSECT: u32 = 0xFFFF_FFFC;
/// Sector used by the FAT.
pub const FATSECT: u32 = 0xFFFF_FFFD;
/// End of a sector chain.
pub const ENDOFCHAIN: u32 = 0xFFFF_FFFE;
/// Unallocated sector.
pub const FREESECT: u32 = 0xFFFF_FFFF;
/// No sibling / child directory entry.
pub const NOSTREAM: u32 = 0xFFFF_FFFF;
/// Streams shorter than this live in the mini stream (§2.2 `Mini Stream Cutoff Size`).
pub const MINI_STREAM_CUTOFF: u32 = 4096;
/// Mini sector size (§2.2: mini sector shift 6).
pub const MINI_SECTOR_SIZE: usize = 64;
/// Longest directory entry name in UTF-16 code units, without the terminating NUL (§2.6.1).
pub const MAX_NAME_LEN: usize = 31;
/// Errors from the reader and writer.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Error {
/// Not a compound file (bad signature or byte order mark).
NotCfb,
/// Structurally invalid or truncated.
Invalid(String),
/// No entry with that name / path.
NotFound(String),
/// A name the format cannot store (too long, illegal characters, duplicate).
BadName(String),
/// The file is well-formed so far but asks for more than a [`Limits`] value or a per-call cap.
LimitExceeded(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::NotCfb => f.write_str("not a compound file"),
Error::Invalid(s) => write!(f, "invalid compound file: {s}"),
Error::NotFound(s) => write!(f, "no entry {s:?}"),
Error::BadName(s) => write!(f, "invalid entry name {s:?}"),
Error::LimitExceeded(s) => write!(f, "compound file limit exceeded: {s}"),
}
}
}
impl std::error::Error for Error {}
pub type Result<T> = std::result::Result<T, Error>;
/// Whether `head` starts with the compound file signature.
pub fn sniff(head: &[u8]) -> bool {
head.starts_with(&SIGNATURE)
}
fn upper(u: u16) -> u16 {
if (b'a' as u16..=b'z' as u16).contains(&u) {
return u - 32;
}
if u < 0x80 {
return u;
}
match char::from_u32(u as u32) {
Some(c) => {
let mut up = c.to_uppercase();
match (up.next(), up.next()) {
(Some(x), None) if (x as u32) <= 0xFFFF => x as u32 as u16,
_ => u,
}
}
None => u,
}
}
/// The sibling order of directory entries (§2.6.4): shorter names first, then a code-unit
/// comparison of the upper-cased names.
pub fn compare_names(a: &str, b: &str) -> Ordering {
// No allocation: the iterators are walked in place (`child` calls this once per sibling).
let (la, lb) = (a.encode_utf16().count(), b.encode_utf16().count());
la.cmp(&lb).then_with(|| a.encode_utf16().map(upper).cmp(b.encode_utf16().map(upper)))
}
/// Check a directory entry name (§2.6.1): 1–31 UTF-16 code units, none of `/ \ : !`, and no NUL
/// (the stored name is NUL-terminated, so a NUL would cut the name short when read back).
pub fn validate_name(name: &str) -> Result<()> {
let n = name.encode_utf16().count();
if n == 0 || n > MAX_NAME_LEN || name.contains(['/', '\\', ':', '!', '\0']) {
return Err(Error::BadName(name.to_string()));
}
Ok(())
}
#[cfg(test)]
mod tests;