mdzip-core 0.1.0

Read and write MDZip (.mdz) archives: Markdown documents and their images in one portable file.
Documentation

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 as mdzip_core.

Reading

use mdzip_core::MdzArchive;

let archive = MdzArchive::open_path("guide.mdz")?;   // or MdzArchive::open(bytes)
println!("{:?} {}", archive.title(), archive.mode()); // Some("My Guide") document
let entry = archive.entry_point()?;                  // e.g. "index.md" (Section 5.5)
let text = archive.read_text(&entry)?;
let logo = archive.read_bytes("images/logo.png")?;
for doc in archive.documents() { /* every Markdown file */ }
for finding in archive.validate() {                  // conformance notes; never fails
    println!("{} {} {}", finding.level, finding.code, finding.message);
}

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.version from a newer major version, and unrecognized mode values.
  • Limits caps 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 in files()), with a warning.
  • Corrupt entry data (a bad CRC-32, broken compression, or data longer than declared) fails the read with ERR_ZIP_INVALID or ERR_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 mdzip_core::{create, Application, CreateOptions};

let data = create(
    [
        ("index.md", "# My Guide\n\n![logo](images/logo.png)\n".as_bytes()),
        ("images/logo.png", &png_bytes),
    ],
    &CreateOptions::new()
        .title("My Guide")                          // defaults to the entry point's first heading
        .application(Application::new("my-editor", "1.0")),
)?;
std::fs::write("guide.mdz", data)?;

let data = mdzip_core::create_from_directory("my-guide/", None, &CreateOptions::new())?; // 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 mdzip_core::{MdzArchive, Update};

let archive = MdzArchive::open_path("guide.mdz")?;
let data = archive.update(
    &Update::new()
        .write("images/new.png", &png_bytes)
        .write("index.md", new_text)
        .remove("old.md")
        .set("title", "Second edition")             // .unset("field") deletes one
)?;

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 with ERR_ENTRYPOINT_CHANGED.

Links

use mdzip_core::{LinkTarget, LinkUse};

match archive.resolve_link("chapters/intro.md", "../images/a.png", LinkUse::Embed) {
    LinkTarget::Internal { entry, fragment } => { /* an entry in this archive */ }
    LinkTarget::Host { path, .. } => { /* a file beside the .mdz */ }
    LinkTarget::Archive { archive, entry, .. } => { /* an entry in another archive */ }
    LinkTarget::Url => { /* https:, mailto:, ... never loaded by this crate */ }
    LinkTarget::Unresolved(reason) => { /* not-found, invalid, denied, ... */ }
}

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

cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt --check

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:

node ../mdzip-spec/tools/sync-fixtures.mjs tests/fixtures --ref v1.2.1

License

Apache-2.0