moonzip

    Compression and archives for MoonBit, one format per package.

    compression
    decompression
    archive
    streaming
    zip
    gzip
    deflate
    zlib
    brotli
    bzip2
    lz4
    lzma
    xz
    zstd
    zstandard
    snappy
    tar
    7z
    7zip
    rfc1950
    rfc1951
    rfc1952
    rfc7932
    rfc8878
    moonbit
    Download zip
    Version
    0.3.6
    License
    Apache-2.0
    Last updated
    16 hours ago
    Downloads
    3K

    #moonzip

    Compression for MoonBit. Thirteen formats, one per package, each with the interface its own specification argues for — and no dependency beyond moonbase and mooncrypt (the checksums, and the AES primitives behind ZIP's encryption).

    let small = @gzip.gzip(body[:])
    let back = @gzip.gunzip(small[:]) // raises if the checksum disagrees

    let archive = @zip.zip([@zip.Entry::new("readme.txt", body)][:])
    let members = @zip.unzip(archive[:])

    PackageWhatSpecification
    deflateDEFLATE, both directions, all three block typesRFC 1951
    gzipThe gzip container, with CRC-32RFC 1952
    zlibThe zlib container, with Adler-32RFC 1950
    zipThe ZIP archive, with Zip64, descriptors, ZipCrypto, WinZip AES and volumesAPPNOTE 6.3
    tarustar and pax, and .tar.gz on topPOSIX pax
    lz4The block and frame formats, with xxHash-32lz4 block, frame
    snappyThe block and framing formats, with CRC-32CSnappy
    lzmaThe LZMA codec and the .lzma containerLZMA SDK
    xzThe xz container over LZMA2xz file format
    bzip2Burrows-Wheeler, move-to-front and Huffman, both directionsbzip2
    sevenzThe 7z container: folders of coders, solid or separate7z format
    zstdFrames, blocks, FSE and HuffmanRFC 8878
    brotliMeta-blocks, contexts, block types and the 122 KB dictionaryRFC 7932

    Run any of them with moon run src/examples/<name>:

    • minimal — the smallest program: compress and read back one buffer.
    • tour — the surface in one go: deflate, gzip, zlib and brotli, the two checksums, and a tampered stream refused.
    • formats — every codec, one-shot, with the size it reached and a round-trip check.
    • throughput — megabytes per second for the one-shot codecs, best of several runs.
    • stream — the streaming pair, both ways, on a body taken a piece at a time.
    • checksums — CRC-32, Adler-32 and CRC-32C, one-shot and streamed a piece at a time.
    • archive — zip (Zip64, descriptors, a password) and tar, tar.gz and 7z.
    • bundle — a small release packer: stream-compress, wrap in four containers, read back.
    • vectors — the byte streams handed to an external checker for conformance.

    Nothing in this library is a stub that returns its input; a format is either implemented or it is refused by name.

    #Configuration

    Every bound the encoder and the decoder have is an argument, and every default is the one the mainstream uses.

    @deflate.deflate(body[:]) // level 6, a 32 KiB window
    @deflate.deflate(body[:], level=9) // search hardest
    @deflate.deflate(body[:], level=0) // store, do not compress
    @deflate.deflate(body[:], blocks=Fixed) // pin the block type
    @deflate.deflate(body[:], chain=2048) // set the search depth directly

    @deflate.inflate(stream[:], limit=1 << 20) // refuse to expand past a megabyte

    @gzip.gzip(body[:], name="report.csv", mtime=written_at)
    @gzip.gunzip(stream[:], checksum=false) // read what a damaged stream holds

    SettingDefaultWhy that one
    level6The usual default; each level maps to the search depths the format's reference tables give it, so a level means the same here as anywhere
    window32768The most DEFLATE allows, and the only size a reader can be assumed to handle
    chainfrom levelFor a caller who would rather set the depth than pick a level
    blocksAutoThe writer measures a dynamic candidate, a fixed one and a stored one per block and keeps the smallest
    limitunboundedWhat every library does. A reader handling input from elsewhere should set it: a compressed stream says nothing about how far it expands
    mtime0So the same input gives the same bytes. A compressor that stamped the clock would make a build unreproducible
    checksum, length, header_crconA container that carries a checksum and does not look at it is not a container. Off is for reading what a damaged stream still holds

    level=0 writes stored blocks, which is what "no compression" means everywhere; blocks=Stored says the same thing without going through a level. level=7 is the search depth this library used before the level existed, so output written by 0.1.0 is still exactly reproducible — a test asserts it.

    #Streaming

    A response body arrives in chunks. A file is read four kilobytes at a time. Neither has to be gathered first.

    // out
    let writer = @gzip.Writer::new(level=9)
    for part in parts {
    for b in writer.push(part) { out.push(b) } // maybe nothing, at first
    }
    for b in writer.finish() { out.push(b) } // the rest, plus the trailer

    // back
    let reader = @gzip.Reader::new()
    for part in chunks {
    for b in reader.push(part) { out.push(b) } // may cross a symbol
    }
    for b in reader.finish() { out.push(b) } // and the trailer is checked here

    Every codec here has the pair, and what each one holds between calls is the format's own unit: one block for deflate, lz4, zstd and brotli's meta-block; one chunk for snappy and xz, which state their sizes; one block for bzip2, whose sort cannot begin until it is complete; and one dictionary window for lzma, which has no blocks at all and carries its range coder from token to token. Nothing holds the body, and nothing holds the compressed form of it.

    FormatUnit heldBound
    deflate, gzip, zlibone block32 KiB
    lz4one block64 KiB
    snappyone chunk64 KiB
    zstdone block64 KiB
    xzone chunk, plus the dictionary1 MiB, plus the level's dictionary
    bzip2one block100–900 KiB, by level
    brotlione meta-block, plus the window1 MiB, plus the window
    lzmathe dictionary64 KiB – 8 MiB, by level
    7zone folderthe folder's dictionary

    zip has a writer that hands back each member's bytes as it adds them and a reader that walks local headers from the front, which is the only way to read an archive that is still arriving. 7z has the same writer — add hands back the compressed bytes each entry produced — but it cannot have the same reader: a 7z directory is written after the folders it describes, so nothing can be cut into members until the last byte has arrived. Its reader takes the archive a piece at a time, refuses a file that is not one on the first six bytes, and decodes folder by folder when the directory shows up, keeping one folder's output rather than the whole archive's.

    A chunk boundary may fall inside a header field, inside a Huffman code, inside a stored block, or between a member's data and its data descriptor. The tests feed the same body in pieces of one, two, three, five, seven, thirteen, sixty-four, five hundred and eleven, four thousand and ninety-six, and sixty-five thousand five hundred and thirty-six bytes, and require the same bytes out each time. The boundaries that break a naive decoder are never the round ones.

    #Archives

    gzip compresses one thing; zip and tar hold many.

    let archive = @zip.zip([
    @zip.Entry::new("hello.txt", b"hello"),
    @zip.Entry::new("dir/nested.txt", body, mtime=written_at),
    ][:])

    @zip.names(archive[:]) // list it, without expanding anything
    @zip.entry(archive[:], "hello.txt") // one member, expanding only that one
    @zip.unzip(archive[:], limit=limit) // all of them, bounded

    Listing does not expand: the central directory is a table at the end of the archive naming every member, so names reads that and stops. This is what zipfile.namelist() is, and the reason to have it.

    zip writes and reads the variants a real archive carries:

    @zip.zip(entries[:], zip64=true) // sixty-four-bit sizes and offsets
    @zip.zip(entries[:], descriptor=true) // sizes after each member, not before
    @zip.zip(entries[:], password="hunter2") // ZipCrypto
    @zip.zip(entries[:], password="hunter2", aes=256) // WinZip AES-256, AE-1
    @zip.zip(entries[:], password="hunter2", aes=256, ae=2) // AE-2, CRC zeroed
    @zip.unzip(archive[:], password="hunter2")

    Zip64 turns itself on when an entry or an offset does not fit in thirty-two bits, because that is the only reason it exists; forcing it is for a caller whose reader expects the wide form. A data descriptor is what a writer that has not finished reading a member must use — it cannot know the sizes when it writes the header.

    tar is the other half of the same idea, with no compressed members: one header per file, the bytes, and two empty blocks at the end. A .tar.gz compresses the whole archive rather than each member, which is why it beats the same files compressed one at a time.

    @tar.tar([@tar.Entry::new("a.txt", body), @tar.Entry::directory("empty")][:])
    @tar.targz(files[:]) // and untargz

    Long names go in a pax extended header; a path that can be split between the name and prefix fields stays in the ustar header, which is what readers from before 2001 can read. Symbolic links, directories, permission bits, owners and base-256 numbers all round-trip.

    On encryption: two schemes, and only one of them is a security boundary.

    ZipCrypto is the cipher the format called encryption for fifteen years, and it is broken — a known-plaintext attack recovers the keys from a dozen bytes. It is here because archives in the wild still carry it, and opening one should not need a second tool. It is not a security boundary, and nothing here will claim it is.

    WinZip AES is the real one. aes=128, 192 or 256 picks the key length; the bytes are encrypted in counter mode under a key derived by PBKDF2-HMAC-SHA1 with a thousand iterations, authenticated with an HMAC-SHA1 tag truncated to eighty bits, and stored as salt, verification value, ciphertext and tag. Both headers carry method 99 and a 0x9901 extra field naming the real method and the strength. AE-1 (the default) keeps the CRC; AE-2 zeroes it, because the tag already proves integrity. Reading catches a wrong password from the two-byte verification value and a tampered payload from the tag, both before a byte is returned.

    Two details of this scheme only ever show up in somebody else's archive. The counter block is read as a little-endian integer, not as the big-endian one the standard counter mode uses — the two agree on the first block, so an archive of short members round-trips either way. And a directory member carries no payload, so an encrypted archive's directory has nothing to decrypt even though it is a member like any other.

    Both were found by scripts/sevenzip_interop.py, which writes all six encrypted shapes and both volume forms here, has 7-Zip extract every one, and pins the archives 7-Zip writes into the test suite: AES-128 and AES-256 in AE-2, a plain archive, and a volume set. The suite reproduces the published WinZip AES vector byte for byte as well, but that vector is thirteen bytes — one block — and the archive of a longer member is what settles the rest.

    Volume splitting: one archive carried in several files, archive.z01, archive.z02, … and finally archive.zip. A split archive is the same bytes cut at fixed boundaries — the central directory is still the last thing in the stream — so rejoining the pieces in order is the archive, and every reader understands it. volume_names gives the names the convention expects; the pieces are ordinary bytes, because this package never touches a file system.

    let parts = @zip.zip_split(entries[:], 65536) // volumes of at most 64 KiB
    @zip.volume_names("archive", parts.length()) // ["archive.z01", ..., "archive.zip"]
    let back = @zip.unzip_split(parts) // if it arrives as several files

    Two models, and the difference is what the headers say. split cuts a finished archive into byte ranges and leaves every offset absolute — the pieces must be rejoined before anything reads them. zip_spanned writes the older multi-disk form instead: each member's directory record carries the disk it starts on and its offset from that disk's first byte, and the end record names the last disk and where the directory begins, which is what an unzip that reads disk by disk expects. unzip_spanned joins the disks, translates every disk-relative offset back to one coordinate space, and reads the result.

    let disks = @zip.zip_spanned(entries[:], 65536) // true spanned disks
    @zip.unzip_spanned(disks) // disk numbers translated back

    The end record has to lie wholly on the last disk, or a reader handed that piece alone cannot find the directory, so the last disk begins at the largest multiple of the size that does not pass it. Every disk before it is exactly the size asked for; the last is the one that may be a little larger. Offsets stay in thirty-two bits, so a member that would need Zip64 is refused rather than written with a field it cannot agree with.

    Verified against libarchive (bsdtar) on the concatenated disks: a member recorded as disk 1, relative offset 3 is read at byte 131, which only works if the disk number is honoured — and it extracts byte for byte. 7-Zip takes both forms when the pieces are named archive.zip.001, .002, … which is what scripts/sevenzip_interop.py checks for every kind of body. Info-ZIP unzip reads the byte-range split form after rejoining, but says outright that it has no multi-part support, so it is not the tool for this one.

    A method this package does not implement — bzip2 is 12, LZMA is 14 — is a Codec away rather than an edit here:

    @zip.unzip(archive[:], codecs=mine[:])

    #LZMA

    A range coder with a binary-probability model: no bit stream to align, one table of probabilities updated per decision, and a matched-literal trick that makes the byte after a match much cheaper than a byte after a literal.

    @lzma.lzma(body[:]) // the .lzma alone container
    @lzma.unlzma(packed[:]) // both a stated length and an end marker

    @xz.xz(body[:]) // the container, one block, CRC-64
    @xz.unxz(packed[:]) // header, block header, index and footer checked

    The xz layer is the LZMA2 chunk framing: a control byte that says how much state to discard, the sizes, and a properties byte when the model changes. The specification puts the block's padding before its check rather than after — which is the detail that made the first version of this stream readable by us and rejected by everybody else.

    Levels 0–9 map to the same dictionary sizes xz uses. The encoder is a greedy hash-chain parse of moderate effort — a stream real decoders read, not the last few percent.

    This is the codec that xz and 7z are built on, which is why it is a package of its own: the container work comes next, and it will not need a second codec.

    #bzip2

    Sort the block's rotations, and equal contexts end up adjacent. Read the last column, and the byte after each context is where the next one starts. That is the whole idea, and the three stages after it exist to make the result encodable: move-to-front turns "the same byte as last time at this context" into a zero, a run of zeros becomes two symbols, and Huffman codes what is left.

    @bzip2.bzip2(body[:], level=9) // 900k blocks
    @bzip2.bunzip2(packed[:]) // block CRCs and the combined one checked

    The encoder is a real one: an exact block sort by prefix doubling, and six Huffman tables assigned by a cost pass and rebuilt from what they were given, four times over — the reference's own loop. On the body in the table below it lands within a per cent of what bzip2 writes.

    Two details cost time and are worth naming, because both produce streams that look fine and read wrong:

    • The selector count is a fifteen-bit field, not a unary run. A reader that assumes unary decodes the first table perfectly and then reads the data as code lengths.
    • Blocks are not padded. bsFinishWrite is called once, after the trailer, so a block boundary can fall in the middle of a byte — which means a streaming reader has to carry a bit position, not a byte offset.

    #7z

    Not a codec — a container whose compression method is a property of each folder. A folder is one compressed stream, and it can hold several files at once, which is what "solid" means: the compressor saw all of them together, so a phrase repeated across two files is paid for once and neither can be read without the other.

    @sevenz.sevenz(members[:], how=@sevenz.Method::Lzma2, solid=true)
    @sevenz.un7z(archive[:]) // folder, substream and signature checksums verified
    @sevenz.names(archive[:]) // the directory only, without touching the data

    Both of those hold the whole thing: every member, every folder's compressed form, and the directory, before anything comes back. The streaming pair does not.

    let w = @sevenz.Writer::new(level=6)
    let mut body : Array[Byte] = []
    for entry in members {
    for b in w.add(entry) {
    body.push(b) // compressed as it arrives; write it out here
    }
    }
    let directory = w.finish()
    let start = w.signature() // the thirty-two bytes that go in front

    The order is the format's, not a choice. A 7z archive opens with a thirty-two byte header that says where the directory sits — which is the length of the packed data, a number nobody has until the last folder is written — so those bytes are produced last and placed first. signature before finish aborts rather than returning something plausible.

    let r = @sevenz.Reader::new(limit=1 << 26)
    for piece in pieces {
    r.push(piece[:]) // the signature is checked on the first six bytes
    }
    let members = r.finish()

    A reader cannot do better than that here, and it is worth saying why: the directory comes after the folders it describes, so no member's boundary is known until the last byte has arrived. What the reader streams is the input and the decoding — it keeps one folder's output at a time rather than the whole archive's, and it refuses a file that is not a 7z archive on its first six bytes rather than after a gigabyte of them. A writer, which does know the order in advance, streams properly: an entry is compressed as it arrives and its bytes leave immediately.

    The reader understands folders whose coders are a straight chain, over COPY, LZMA, LZMA2, BZIP2, DEFLATE and the delta filter, and reads real 7z archives including ones whose directory is itself a compressed stream — which is the normal shape, because a directory that is a stream is how a thousand-file archive keeps its index under a few kilobytes.

    What it refuses, by name rather than by guessing: AES encryption (06 f1 07 01), the branch filters (03 03 …: x86, ARM, ARMThumb, PPC, SPARC, IA64), BCJ2 (a folder that is a graph rather than a chain), PPMd, and the LZ4 and Zstd coders. The error carries the method id, so a caller learns what it met.

    Three things about this format are worth remembering, because each one is a plausible mistake that produces a stream only its author can read:

    • The length encoding is n bytes for 7n bits, with the first byte holding the high bits: 80 97 is 151, not 38656. A writer that gets this wrong is correct for every value below 16384.
    • A coder id of length zero is not "no coder" — it is how COPY is spelled.
    • A file table entry is a boxed record with mutable fields; filling an array with one gives every slot the same box, and every entry becomes the last one written.

    The writer emits an uncompressed directory, which is legal and which any reader must accept. The reader accepts both forms.

    #zstd

    Two entropy coders over one LZ77 parse. FSE is an ANS variant — a state machine whose transitions are probability-weighted — which is what lets a symbol cost a fractional number of bits; Huffman codes the literals and FSE codes everything else.

    @zstd.zstd(body[:], level=3)
    @zstd.unzstd(packed[:])

    The reader is complete: all three block types, literals raw, RLE or Huffman-coded with one or four streams, every FSE table mode (predefined, single-symbol, described, repeated), repeat offsets including the zero-literals shift, skippable frames and concatenated frames.

    The writer is real but modest: an LZ77 parse coded with the predefined FSE tables and raw literals. That is a legal frame, and it compresses — 184,000 bytes of English text to 3,042 — but it leaves the two things the reference encoder does for ratio: per-block table selection, and Huffman coding of the literals.

    Three things about this format cost time and are worth naming:

    • The bitstreams are read backwards. The last byte holds a stop bit: 0-7 zeros and then a one, with the data below it.
    • An FSE decoding table seeds each symbol's next-state counter at that symbol's count, not at where its block of states begins. Getting that wrong decodes every symbol correctly and every next state wrongly — which reads as "the first sequence is right and everything after it drifts".
    • The encoder must not spend bits on the last sequence's state update, because the decoder does not read one; it seeds the three states from those symbols instead.

    #brotli

    Three ideas stacked on the same LZ77 skeleton as DEFLATE, each with its own chunk of the format:

    • Block types. Literals, commands and distances are three separate symbol streams, and each may switch between up to 256 sets of prefix codes, with the two most recent types addressable in three bits. A file with prose and a table of numbers gets one model for each.
    • Literal contexts. Every literal is coded with one of 64 sub-alphabets, chosen by the two bytes before it — a hand-written model of where in a UTF-8 character the reader is. This is where most of brotli's edge over DEFLATE comes from on text, and it is the reason utf8 and signed are context modes rather than a detail.
    • A static dictionary. 13,504 common words in several languages, plus 121 transforms over them, reachable by a distance larger than the window. A word the encoder recognises costs a distance code instead of its letters.

    @brotli.brotli(body[:], window=22) // window is 10 to 24
    @brotli.brotli(body[:], quality=11) // search hardest; quality is 0 to 11
    @brotli.unbrotli(packed[:], limit=1 << 24)

    The reader is complete: both prefix-code spellings, all four context modes, block switching for all three streams, context maps with run-length coding and move-to-front, the distance ring with all sixteen short codes, and every one of the 121 dictionary transforms. It reads streams at qualities 0 through 11 and windows 10 through 24, including quality 11 on English prose, which is where the dictionary does its work.

    The writer is LZ77 with brotli's three models bolted on. A hash chain and Huffman codes from exact counts at every position; from quality 9 up, literal contexts, block types for the command and literal streams, and the static dictionary. quality (0 to 11, default 9) is how hard it looks. Up to 8 that is the chain depth and a lazy step, which is the whole of a greedy parse, and 0 is the single-candidate "fast" search LZ4 ships. From 10 the parse is chosen rather than taken: a walk over the block keeps the cheapest price of reaching every position, priced from the codes the round before produced, and every round's result is encoded to see which is genuinely smaller. On the body in the table below — 184,000 bytes of repetitive English — quality 9 reaches 518 bytes, ahead of gzip at 1,983 and zstd at 3,042, because one long match is worth more than everything the fancier models add.

    • Literal contexts. The writer picks a context mode — UTF8 for text, LSB6 for binary — counts every literal in one of 64 sub-alphabets keyed by the two bytes before it, and then groups those sub-alphabets into at most six codes. The grouping is a recursive bisection: each round splits the group whose split pays best, and stops when no split pays or the cap is reached. The routing table that says which context uses which code is sent with the stream, so a body whose contexts look alike comes out with one code and one bit of map.
    • Split streams. The command stream may be cut in two, and so may the literal stream, each half carrying its own prefix code and — for literals — its own context map. Where each cut falls is searched; both are declined when the extra code does not pay.
    • The static dictionary. Words the format already knows are coded as a dictionary reference — a distance past the window that names (transform, word) — instead of their letters. The writer indexes the 13,504 words by their first four bytes and probes each position twice: once as spelled, once with the first letter lowercased, so a sentence-initial Time costs the same code as time. It then reaches 103 of the 121 transforms. With no prefix the word starts at the position being searched, so that index finds it and the work is comparing the suffix after it; with a prefix the text starts with those bytes and the word starts just past them, so the same index works one probe further in. That covers word + " ", word + ".", " " + word, " the " + word + " of " and the capitalised and shouted spellings of each. Eight are out of reach because they drop the word's opening bytes, which are exactly what the text does not contain. Ten more — the ones that drop the word's tail — are reachable and declined: a truncated word covers less text for the same far distance, and on this file they cost 260 bytes at every minimum length tried. An affix is not part of the copy length either: the command carries the word's length, which selects the bucket, and the transform decides how much comes out, so time with the space-suffix transform is a copy of four bytes that produces five.

    Every one of those choices prices what it is choosing instead of estimating it: the candidate code is written to a throwaway bit stream and counted. An estimate of "so many bits per used symbol" is wrong by enough to accept splits that then come out larger than the stream they were meant to shrink, which is exactly what happened here first — a body of short repeated phrases grew by six bytes at quality 11. With exact pricing, turning any of them on can no longer cost bytes, and a test pins that.

    The literal cut is the one that varies most with the body, and it is measured in both directions. On this repository's own documentation — README, homepage and guide, about 111 KB, which is what the pages you are reading add up to — quality 9 gives 29.2 KB against the reference encoder's 29.1 KB on the same bytes: level, which is where a writer and its reference meet when the models match. Quality 11 gives 26.7 KB against the reference's 26.4 KB, and the same body through the search this package had before came out at 27.6 KB, so the parse change is worth 3.3%.

    Three things were added, and one measurement is worth more than the three descriptions. The insert run is a state: the search keeps the eight cheapest positions a command could have ended at, ordered by what they cost beyond coding the bytes in between as literals, and tries copies from the five cheapest, because a command's insert length is coded jointly with its copy length. The ring of recent distances is in the search: all sixteen short codes are tried at every position against the ring the cheapest path there would have left behind, because those distances are not in the match list — the chain was built before the parse decided anything. The candidate list is thirty-two deep rather than six.

    Of the three, keeping the start positions is where the bytes are: a search with one start instead of five writes 1.1% more on the same body. The ring is close to a wash in bytes — 0.36% better on prose, 0.2% worse on a body of words and numbers — and the reason is that this writer already had a pass that rewrites each copy to a cheap distance when the bytes allow it (cheapen), which is the job the reference puts in its search because it has no such pass. What the ring buys here is the price model: with a cheap distance actually reachable, a copy can be charged the symbol it takes instead of what a repeat costs, and the search stops having to be lied to. The deeper candidate list stops paying past a dozen on every body measured; thirty-two is the reference's order of magnitude, not a measured win.

    Every new form is checked against the reference decoder, not just this one: a quality-11 stream over a 101 KB body, whose header declares two block types and a literal context map where quality 9 declares one of each, decodes through brotli 1.2.0 to the same bytes — including the dictionary references, which the reference reader resolves against its own copy of the blob, and the copies whose transform produces more bytes than the command's copy length. Text, binary and two-alphabet bodies were checked the same way, because each takes a different path through the writer.

    Four things about this format cost real time:

    • The code-length code is written as a prefix code, not as four raw bits per value. It looks like a fixed-width field and is not, and a writer that assumes otherwise produces a stream whose trees are almost right.
    • A code-length code with one symbol costs zero bits. The reference zeroes that symbol's width after writing the header. A reader that charges one bit per run length desynchronises on the very next symbol, and the streams that hit it are the ones where every literal has the same width — the alphabet, not the prose.
    • The dictionary transform table holds fragment ids. The 49-prefix table is reached through a second indirection; indexing it with the transform's own position reads past its fifty entries.
    • The simple prefix-code form does not transmit widths. The reader derives them from the number of symbols and always gives the shortest one to the symbol it read first, so a writer has to assign widths the same way — with three symbols the frequency-based choice and the positional one disagree.

    #The formats chosen for speed

    lz4 and snappy trade ratio for decode speed, and the numbers below are what the trade costs on the same body — 184,000 bytes of English text with a periodic tail, printed by src/examples/stream.

    FormatBytesOf the original
    gzip1,9831.1%
    lz4 block1,9971.1%
    lz4 frame (three 64 KiB blocks)4,5762.5%
    deflate, dynamic blocks2,6611.4%
    zlib2,6671.4%
    snappy block9,8845.4%
    snappy frame (three 64 KiB chunks)12,4396.8%
    bzip28530.5%
    xz4680.3%
    lzma, alone4150.2%
    zstd3,0421.7%
    brotli5180.3%
    — nothing184,000100%

    The three at the bottom are the block-sorting and range-coding end of the field: they find the repetition that DEFLATE's 32 KiB window cannot see and spend a lot of setup finding it. That lz4 beats DEFLATE here is the body's fault too: four thousand repetitions of one sentence are exactly what a two-byte back-reference is for. On prose, DEFLATE wins, because Huffman coding of literals is a thing lz4 does not do at all. The frame sizes are larger than the block sizes because each block restarts the match finder, which loses the cross-block matches a repetitive body lives on.

    #Throughput

    Sizes are one axis; the other is time. These are release-build numbers from src/examples/throughput on one machine, the median of ten runs after a warm-up, over a 1.5 MB body of words and numbers that compresses 3.4 to 1. A rate on a shared machine is a range rather than a constant — this one moves by a factor of two between runs — so read them as orders of magnitude.

    CodecWrite MB/sRead MB/s
    deflate16150
    gzip16107
    zlib14130
    zstd1735
    brotli (quality 9)1.152

    The same body through C zlib on the same machine — zlib.compress(_, 6) from Python, median of ten, which scripts/throughput_reference.py reproduces — writes at about 16 MB/s and reads at 349. The deflate writer is therefore at zlib's own write rate, and reads at something under half of its decode rate: 143 to 153 MB/s against 349, measured in the same session.

    Everything there is a constant factor, and all of it is in the two loops that touch one byte at a time. Writing: a match is compared four bytes at a time through an unaligned word read rather than byte by byte, which is where a match finder spends its time; the Huffman codes are reversed once when the table is built instead of per symbol; the length and distance symbols are table lookups; the bit writer holds a buffer it doubles instead of pushing bytes through a growable array; and the block that wins the auto choice is spliced into the stream rather than written a second time. Reading: the block loop holds the bit state, the tables and the output cursor in locals instead of reloading them from three structs per symbol, and writes into a buffer of its own rather than through a growable array's push. And the one-shot inflate no longer copies its result into a fresh array to append nothing to it.

    Two earlier changes are still what make the reader worth having. It keeps the last 32 KiB of what it decoded instead of all of it — a back-reference reaches one window back, so the rest was a megabyte of copies nothing could read. And the Huffman decoder looks nine bits ahead, naming most codes with one lookup. The look-ahead is also what broke the compressed-to-stored transition the first time: pulling bytes into the accumulator is fine until align throws that accumulator away and takes whole bytes of the stored block's payload with it. The mistake was to conclude that the reader cannot look ahead. It is that align has to hand those bytes back by rewinding the read position — and so does rest, because a trailer is exactly what a look-ahead leaves in the accumulator — and that a reader which stops with whole bytes still in the accumulator has to un-pull them before it drops what it has consumed. That last one is a bug this reader had for as long as it looked ahead at all, and only a body fed one byte at a time found it.

    brotli's write rate is the price of its search: quality 9 walks up to 256 chain candidates at each position. quality=1 is several times faster for a few per cent more bytes, which is the trade the knob exists to expose.

    #What it reads and what it writes

    The DEFLATE reader takes all three block types, including the dynamic Huffman blocks that zlib, gzip and every browser actually emit. A reader without those reads almost nothing in practice, which is why it was not a later step.

    The DEFLATE writer emits all three as well, and chooses per block: a dynamic candidate is written to a probe writer, measured, and re-emitted only if it won; the fixed and stored costs are arithmetic. This is what zlib does, and it is why blocks=Auto is the default.

    Both containers check their checksum and their length on the way back.

    Every writer is reproducible: gzip writes a zero modification time and no file name, ZIP stamps the epoch, tar takes the time it is given, ZipCrypto derives its twelve-byte header from a hash of the name, time and password rather than a random source, and AES derives its per-entry salt from the name, time, entry offset and password — folding the offset in so two same-named entries still get distinct keys. The same input gives the same bytes, so two builds can be compared and a cache entry can match.

    #Where the checksums live

    CRC-32 and Adler-32 come from mooncrypt's sum package and are re-exported at the root, because more than one container specifies them — gzip puts CRC-32 in its trailer, zip in every entry — and none of them should import another to reach it. CRC-32C stays here, because mooncrypt does not carry it and snappy puts it in every chunk. Each has a struct beside the one-shot function (Crc, Adler, Castagnoli) for a body that is compressed as it arrives and cannot be checksummed in one call.

    #Why one package per format

    A subpackage already isolates what a caller carries: importing moonzip/gzip does not bring snappy with it. There is no umbrella package re-exporting them all — that would undo exactly what the split achieves, and pub using cannot carry an enum's constructors through anyway.

    The vocabulary is shared and fixed: Refused with its offset for every failure, BytesView in and Bytes out, raise only on the reading side, ~ for a default a caller may want to change and ? for one that changes behaviour rather than size.

    #Install

    moon add moonbitstack/moonzip

    #Licence

    Apache-2.0.

    Refused

    pub(all) suberror Refused {
    Truncated(at~ : Int)
    Header(at~ : Int, what~ : String)
    Code(at~ : Int)
    Distance(at~ : Int)
    Checksum(want~ : UInt, got~ : UInt)
    Length(want~ : UInt, got~ : Int)
    TooBig(limit~ : Int)
    } derive(Eq,
    Debug
    )

    Why a compressed stream could not be read.

    The offset is where the reader was when it gave up, in bytes from the start of the stream — enough to find the damage in a file, which "corrupt data" is not.
    impl Show for Refused

    Refused::equal

    fn Refused::equal(Refused, Refused) -> Bool

    Refused::not_equal

    fn Refused::not_equal(x : Refused, y : Refused) -> Bool

    Refused::output

    fn Refused::output(self : Refused, logger : &Logger) -> Unit

    Refused::to_repr

    Refused::to_string

    fn Refused::to_string(self : Refused) -> String

    Adler

    Adler-32 taken a piece at a time, for a body that is never held whole. The running state and the folding come from mooncrypt/sum/adler32.

    Adler::finish

    fn Adler::finish(self : Adler) -> UInt

    Adler::new

    fn Adler::new() -> Adler

    Adler::push

    fn Adler::push(self : Adler, data : BytesView) -> Unit

    Castagnoli

    pub(all) struct Castagnoli {
    state : UInt
    }

    CRC-32C taken a piece at a time, for a body that is never held whole.

    Castagnoli::finish

    fn Castagnoli::finish(self : Castagnoli) -> UInt

    Castagnoli::new

    fn Castagnoli::new() -> Castagnoli

    Castagnoli::push

    fn Castagnoli::push(self : Castagnoli, data : BytesView) -> Unit

    Crc

    pub(all) struct Crc {
    inner :
    Crc32

    }

    CRC-32 taken a piece at a time, for a body that is never held whole.

    A container that compresses a body it never holds cannot checksum it in one call either: the streaming writers in gzip and zip keep one of these beside their compressor and hand it each chunk as it goes past. The running state and the folding come from mooncrypt/sum/crc32.

    Crc::finish

    fn Crc::finish(self : Crc) -> UInt

    Crc::new

    fn Crc::new() -> Crc

    Crc::push

    fn Crc::push(self : Crc, data : BytesView) -> Unit

    adler32

    fn adler32(data : BytesView) -> UInt

    Adler-32 (RFC 1950): two running sums modulo the largest prime below 65536. Cheaper than a CRC and weaker, which is the trade zlib made. The folding is mooncrypt/sum/adler32, reached from here for the same reason crc32 is.

    crc32

    fn crc32(data : BytesView) -> UInt

    CRC-32 as gzip, ZIP and PNG use it (RFC 1952): the reflected form of the IEEE 802.3 polynomial, starting and ending complemented.

    The folding is mooncrypt/sum/crc32; it is reached from here so the formats that specify it — gzip in its trailer, zip in every entry and again in the central directory — find it in one place instead of each importing the other.

    crc32c

    fn crc32c(data : BytesView) -> UInt

    CRC-32C, the Castagnoli polynomial: the checksum that has hardware support on every x86 since Nehalem and every ARM since v8, which is why Snappy and iSCSI chose it over the older CRC-32 that gzip and ZIP use.

    It lives here beside the other two for the same reason they are here: it is a checksum the formats specify, not something a format invents, and a caller comparing two of them should find them in one place.

    mask_crc32c

    fn mask_crc32c(sum : UInt) -> UInt

    The mask Snappy applies to a CRC-32C before putting it in a frame, so that a checksum never looks like the length in front of it — and so that a reader that echoes the bytes back cannot be fooled into accepting them.