mdzip-core (Rust)
Read and write MDZip (.mdz) archives in Rust: one or more Markdown documents and their images and other assets, packaged in a single ZIP file.
This crate implements the MDZip specification 1.2.0. It's a standalone implementation (not a binding to the JavaScript, .NET or Python cores) and it passes the specification's shared conformance fixtures, the same ones every MDZip core runs. An archive written with this crate opens the same way in the other MDZip tools, and theirs open here.
- Rust 1.85+
- Small, pure-Rust dependencies:
miniz_oxide(DEFLATE),crc32fast,unicode-normalization,serde_json. No C code, no async runtime. - Install with
cargo add mdzip-core, import asmdzip_core.
Reading
use MdzArchive;
let archive = open_path?; // or MdzArchive::open(bytes)
println!; // Some("My Guide") document
let entry = archive.entry_point?; // e.g. "index.md" (Section 5.5)
let text = archive.read_text?;
let logo = archive.read_bytes?;
for doc in archive.documents
for finding in archive.validate
The archive is held in memory; nothing is extracted to disk.
Opening is safe for untrusted input:
- It rejects invalid or encrypted ZIPs, entry names that are absolute, contain
.., empty or.segments, or reserved or control characters, and entries that duplicate or conflict with each other after Unicode NFC normalization. - It rejects a
spec.versionfrom a newer major version, and unrecognizedmodevalues. Limitscaps entry count, total and per-entry size, and compression ratio. Sizes are checked against the ZIP directory when opening, then again while reading, since declared sizes can lie. Entries whose data overlaps another's are rejected.- Symbolic links are never followed or read. They're listed in
special_files()(not infiles()), with a warning. - Corrupt entry data (a bad CRC-32, broken compression, or data longer than declared) fails the read with
ERR_ZIP_INVALIDorERR_LIMIT_EXCEEDED.
files() lists file entries. Directory entries the archive stores, such as an empty folder, are in directories() (each ends in /); find_directory("docs") finds one with or without the slash.
Path lookups follow the spec's matching rule: case-sensitive, after NFC normalization. So café.md is found however it's encoded, and Index.md doesn't match index.md.
The manifest is a serde_json::Map in its original field order (archive.manifest()), so fields this crate doesn't know are kept.
Writing
use ;
let data = create?;
write?;
let data = create_from_directory?; // Markdown + images
Every archive gets a manifest.json with spec.version (1.2.0), entryPoint, title and mode, as the spec requires of producers. The entry point is the one you name, or else found by the spec's rules: index.md, or the only Markdown file at the root (support files such as README.md are set aside). Paths are written in NFC with UTF-8 names. Files are DEFLATE-compressed when that makes them smaller. Markdown files get LF line endings; other files are written exactly as given, since some formats (Windows batch files, RFC 4180 CSV) depend on CRLF. Use .keep_line_endings() to keep CRLF in Markdown too.
Updating
use ;
let archive = open_path?;
let data = archive.update?;
update follows the spec's rules for modifying archives:
- Entries you don't touch are copied as stored: same bytes, compression, timestamp and attributes. Each is checked before it's copied, so corrupt data is never passed on.
- Unknown manifest fields are kept, at every level, in their original order.
- Directory entries are kept, including empty folders.
.remove("empty")drops one. - Symbolic link entries are kept unchanged too, so the archive stays non-conforming and the warning from opening it still applies. Use
.drop_special_files()or.remove(name)once the user agrees to remove them. - A manifest is added if the archive has none.
- The entry point must resolve to the same file afterwards. Use
.entry_point(path)to change it on purpose; otherwise the update fails withERR_ENTRYPOINT_CHANGED.
Links
use ;
match archive.resolve_link
Links resolve per spec Sections 9.3–9.6: inside the archive, out of it to files next to the .mdz, and into archives like Project.mdz/docs/design.md. They're never clamped at the archive root. Opened from a file, links can reach the archive's own folder; opened from bytes, links that leave it are unresolved. Use resolve_link with a FilesystemHost to resolve against a larger folder, or implement LinkHost for anything else.
The host's folder is the trust boundary (Section 9.6): targets outside it are denied, whether or not they exist. Narrow it with HostOptions::trust_root, or allow specific targets with allowed_paths. Resolve automatically loaded resources, such as images, with LinkUse::Embed: embeds that leave the archive are denied unless the host sets load_outside_resources, and even then only inside the boundary.
Errors
Failures return MdzError, whose code() is a stable ErrorCode; code().as_str() gives it as the spec writes it. Codes follow the spec's suggested categories (Section 16.5), for example ERR_ENTRYPOINT_INVALID, ERR_ENTRYPOINT_MISSING, ERR_DUPLICATE_PATH, ERR_ZIP_ENCRYPTED and ERR_LIMIT_EXCEEDED, plus a few specific to this crate (ERR_ENTRYPOINT_CHANGED, ERR_NOT_FOUND, ERR_TEXT_INVALID, ERR_IO). Compare codes, not messages.
Core contract
How this crate covers the required operations in the MDZip core library contract:
| Contract | Rust |
|---|---|
| R1 open/read | MdzArchive::open (bytes), open_path, open_with_limits |
| R2 entries and metadata | files, directories, special_files, documents, find, find_directory, contains, entry_info, read_bytes, read_text, manifest, entry_point, mode |
| R3 links | MdzArchive::resolve_link, resolve_link with a LinkHost |
| R4 validation and errors | validate() diagnostics; MdzError::code; open-time rejections |
| R5 creation | create, create_from_directory |
| R6 modification | MdzArchive::update with Update (write, remove, set, unset, entry_point) |
| R7 safety | Path, duplicate, overlap, symlink and link rules on open; Limits (built-in defaults, configurable) |
Optional extensions: configurable resource limits, host link resolution (FilesystemHost) and packaging filters (create_from_directory, matches_filters). Image analysis and a workspace/editor model aren't included.
Every consumer test (T-001 to T-018) and producer check (P-001 to P-008) in the spec's fixtures runs in cargo test, except T-016 (hostile content), which applies only to apps that render Markdown. An app that renders documents from this crate should run T-016 itself.
Development
tests/fixtures/ is a copy of the spec's shared fixtures, made with the spec's sync tool and checked by SPEC-FIXTURES.json (the fixtures_match_marker test). Don't edit it by hand. To refresh it, with mdzip-spec checked out alongside:
License
Apache-2.0