LunaPack

    Application distribution packer built with MoonBit

    packaging
    distribution
    cli
    Download zip
    Author
    Version
    0.1.14
    License
    MIT
    Last updated
    9 minutes ago
    Downloads
    42

    No dependencies

    #LunaPack

    LunaPack

    mooncakes release license

    LunaPack is an application-distribution packer written in MoonBit. It bundles an application executable together with the files it needs into a single distributable file, and unpacks it again on the target machine with integrity checking.

    The result is a self-extracting bundle — the trick a WinRAR or 7-Zip self-extracting archive uses — with three properties the usual archiver does not offer: every entry carries a SHA-256 checksum and an MD5 fingerprint, the entries can be encrypted to a public key or to a passphrase, and the produced file behaves like the program it carries — its arguments and its exit code are the ones of the bundled program, and a program that draws a window is started without a console window standing next to it.

    One container, two front ends: a native binary that is built for the platform it runs on, and a WebAssembly build that moonx runs from the Mooncakes registry. The package has no dependencies, so nothing has to be resolved when it is fetched.

    #Features

    • A bundle that is the program, not a wrapper around it

      The archive is appended to the LunaPack launcher, so the produced file is the launcher — and the launcher is the program the user starts. The arguments of the bundle are the arguments of the bundled program, the exit code is its exit code, and a bundle that was started by a double-click takes its own console window away before a program that draws a window starts, so the application is alone on the desktop. A copy of the executable would not do this; a plain archiver cannot do it at all.

    • Every entry is checksummed, and every entry is fingerprinted

      The SHA-256 checksum of an entry is computed over the original content, so it validates the data the user actually gets and not the compressed form. The MD5 fingerprint of the same content is carried next to it, because the MD5 is what the platform tooling shows, and a user who wants to compare the file with what their file manager reports has nothing to compare against otherwise. The fingerprint of the produced file is reported as well, and it is computed while the file is written, so a 300 MB package is never read back for it.

    • Compression that is a choice, not a default

      --compression-method picks none, gzip, lz4 or auto. auto compresses every entry with gzip but keeps the raw bytes when gzip would make the entry bigger. The LZ4 implementation lives in this package, because the MoonBit toolchain ships none, and what it produces is a standard .lz4 frame: the lz4 tool decompresses it and this package decompresses what that tool produces. The frames are checked against the values the format publishes, not against the tool — see Verification.

    • Encryption to a public key, or to a passphrase

      The mechanism is the one of the reference implementation this package follows: a key is established with the recipient, HKDF-SHA512 turns it into the key of the package, and every entry is sealed on its own with ChaCha20-Poly1305 under a key and a nonce derived for it. The additional data of an entry holds its place in the package and its name, so neither can be changed without the seal breaking. A check in the manifest tells a wrong key from a damaged package.

    • Every primitive is implemented here, and checked against its vector

      SHA-512, HMAC, HKDF, PBKDF2, ChaCha20, Poly1305, X25519 and Ed25519 — the toolchain ships none of them. Each one is checked against the vector that its standard publishes (FIPS 180-4, RFC 4231, RFC 5869, RFC 8439, RFC 7748, RFC 8032), and the derivation is checked against the reference implementation itself: the master key, the key and the nonce of a record, and the sealed record are compared byte for byte with the values the reference produces.

    • A package that says what it is, and a host that can prove it

      The manifest of a package carries the SHA-256 of its own bytes, and a sealed package carries a MAC of it as well, so a manifest that was changed is refused instead of being obeyed. A damaged package is refused rather than taken for something else, the package is written in one piece so a crash cannot leave half of one behind, and the program of a bundle is checked before it is started.

      The one thing a package cannot settle by itself is whether it is the package the publisher meant, because the digest inside it is not a secret. The host settles it: lunapack sign writes a detached Ed25519 signature next to the package and lunapack verify --signature checks it, or the host publishes the digest and lunapack verify --expected-sha256 checks that.

    • An icon that survives the round trip

      When the application carries an icon, the bundle gets it as well, so Explorer shows the application's own icon instead of the launcher's. The icon is read from the application executable with a small PE reader and written into the bundle with the Windows resource API, which creates the resource section the launcher does not have. All icon sizes are copied, so the bundle looks right at every zoom level.

    • Install scripts that run on the platform they were written for

      The script body is stored verbatim in the manifest together with the platform it targets, which is inferred from the file extension. At unpack time the script matching the host platform runs and a script for a different platform is skipped, so one package can safely be unpacked anywhere.

    • One format, two front ends

      The same library and the same container behind lunapack (a native binary) and moonx riantr/LunaPack@latest (the WebAssembly build on the registry). The archive is found from the tail of the file in both cases, so verify, info and unpack work on a bundle exactly as they work on a plain package.

    #What you get

    The module is a library and an executable, so "what you get" is a set of data structures, reports and one command line. Concretely:

    You callYou get backShape
    @LunaPack.pack(dir, pkg, opts)the package, writtenUnit (the manifest is in the file)
    @LunaPack.bundle(exe, out, dirs, opts, launcher)the self-extracting single fileUnit
    @LunaPack.unpack(pkg, dest, run_scripts, key)the tree, extracted and verifiedUnit
    @LunaPack.verify(pkg, key)whether every entry is intactBool
    @LunaPack.read_package_manifest(pkg)the manifest of a packageManifest
    @LunaPack.probe_archive(path)the offset of the archive and its manifest(Int64, Manifest)?
    @LunaPack.verify_extracted_dir(pkg, dir)whether the files in dir still match the packageBool
    @LunaPack.new_pack_options(...)the options of a packagePackOptions
    @LunaPack.new_script(platform, body)an install scriptScript
    @LunaPack.recover_encryption(info, key, passphrase)the key of a sealed packagePackageKey
    @LunaPack.x25519_public_key(secret)the public key of a secret keyBytes
    @LunaPack.ed25519_public_key(seed)the public key of a signing seedBytes
    @LunaPack.ed25519_sign(seed, message)a signature of a messageBytes
    @LunaPack.ed25519_verify(public, message, signature)whether a signature holdsBool
    @LunaPack.public_key_pair(secret)the two public keys of a secret keyPublicKeyPair
    @LunaPack.ed25519_seed(secret)the signing seed of a secret keyBytes
    @LunaPack.save_public_key_pair(path, encryption, signing)the public key file of a pairUnit
    @LunaPack.load_public_key(path)the encryption public key of a fileBytes
    @LunaPack.load_signing_public_key(path)the signing public key of a fileBytes
    @LunaPack.sign_package(pkg, secret)the signature of a packagePackageSignature
    @LunaPack.verify_package_signature(pkg, sig, public)whether the signature holdsBool
    @LunaPack.verify_package_digest(pkg, digest)whether the package has that digestBool
    @LunaPack.manifest_digest(offset, length, bytes)the digest of a manifestBytes
    @LunaPack.manifest_mac(key, offset, length, bytes)the MAC of a manifestBytes

    Every one of those is an explicit data value or a plain function: there is no hidden global state, and the same inputs give the same bytes — which is why moon test can assert on them and why the end-to-end suite can compare a bundle with its own payload byte for byte.

    #Install

    moon add riantr/LunaPack@0.1.14

    That is a module dependency, and it belongs in the consumer's moon.mod. moon add writes it for you, so the file ends up holding:

    import { "riantr/LunaPack@0.1.14", }

    The library is the package riantr/LunaPack, and the package import belongs in the consumer's moon.pkg:

    ///|
    import {
    "riantr/LunaPack" @lib,
    }

    The two blocks look alike and are not the same thing. moon.mod names a module and pins the version it resolved; moon.pkg names a package inside a module and takes no version at all. A version in a moon.pkg import is not a newer spelling — it is an import the toolchain cannot find.

    For the same reason there is no @latest: the version is parsed as semver, so latest, ^0.1.14, >=0.1.14, 0.1 and v0.1.14 are all rejected. A bare moon add riantr/LunaPack resolves the newest release and pins the concrete version it found — that is the "latest" this toolchain has.

    The package can be given a shorter name, which is the closest thing here to Python's from x import y as z:

    ///|
    import {
    "riantr/LunaPack" @lp,
    }

    so that the call is @lp.pack(...). The alias belongs to the package, not to a file: every file in that package sees it, and an import statement inside a .mbt file is rejected outright — "Invalid import declaration here. Move this declaration to moon.pkg."

    The module is published as one package that is both the library and the executable: the root package riantr/LunaPack carries the whole public API and the entry point, so the registry stores the prebuilt WebAssembly module under the module's own name and the coordinate that runs it is the short riantr/LunaPack@latest. There is no second package to name.

    The module carries no dependencies: everything it needs comes with the MoonBit toolchain, so the published module carries none either and nothing has to be resolved when it is fetched.

    #What the release was checked on

    moon 0.1.20261102 — the toolchain every number in this file was measured on.

    The workflow installs the toolchain from the official install script, which serves the current release, so the runner's version moves with it. That is the toolchain this release was verified against, not a tested minimum: no older toolchain has been exercised here, and quoting a lower bound nobody ran would be a claim, not a fact.

    #What changed in 0.1.14

    • A double-clicked bundle of an application with a window starts at all. bundle used to copy the subsystem of the bundled program onto the launcher, so an application that draws a window produced a bundle that says "windowed". A windowed program has no standard streams at all, and the MoonBit runtime does not survive that: the process is gone before the first line of main runs — no window, no message, and not even a cache folder. That is what every double-clicked bundle of a GUI application did, and it is why the same file worked from a command line (a console was there) and did nothing at all from the desktop. Measured on this machine: the tool's own binary, its subsystem field changed to GUI and nothing else, exits with 1 and writes nothing when it is started with no standard streams; the same bytes started from a console print their usage.
    • The console the bundle is given is taken away instead. The bundle keeps the console subsystem of the launcher, which is what makes it start, and the launcher takes away the console that comes with it. It asks GetConsoleProcessList whether the console has only this process on it — the console of a terminal has the shell on it as well, and that one is never touched, because it is the window the user is typing into — and it does so only for a program whose PE header says it draws a window; a console program keeps the window its output goes to.
    • The program is started before the console goes away. FreeConsole destroys a console that has no other process on it, and after that the standard handles of the launcher lead nowhere: a spawn that hands them on fails with "the handle is invalid". The launcher therefore starts the program with CreateProcessW of its own, keeps the process handle, frees the console, and waits — so the exit code of the bundle is still the exit code of the program, which is the contract the command line depends on.
    • The subsystem of the launcher is no longer rewritten. (windowed) no longer appears in the summary of bundle, and a non-PE input can no longer dictate anything about the bundle. set_pe_subsystem and pe_subsystem_of are unchanged for library users; the launcher reads the subsystem of the program it starts.

    Two limits are worth stating plainly. While the payload is unpacked on the first run — 38 s for the 375 files and 123 MB of the application this was reproduced with — the console window is visible and empty, and a warm cache starts in 8 s. And a failure of a double-clicked bundle is silent, because the console it would print to is gone by then; a message box for that case is not in this release.

    #What changed in 0.1.13

    • A cache is handed out on what it holds, not on its name. The payload is unpacked into a folder of its own and moved into the place the next run looks at, and the move was the one step the launcher never looked at: the folder that arrived was named like a complete cache whether all of it arrived or only part of it did. The reported cache held 296 of the 376 files the package carries — the missing 80 were a contiguous tail — with the completion marker in it, and the bundle started the application from it. The application then failed to load Microsoft.WindowsAppRuntime.dll, which was one of the files the move had left behind: a DllNotFoundException from deep inside an application that has nothing to do with unpacking. The folder is read back after it arrives now, and a folder that does not hold what the package carries is removed rather than started. The fall back to a temporary directory — used when no cache can be written next to the executable — is read back in the same way; it checked the main program only, so a sibling library that did not arrive was never noticed, which is the same failure by another road.
    • The module is MIT. LICENSE carried the Apache-2.0 text and moon.mod carried its identifier. Nothing in this package needs the patent grant or the notice file that Apache-2.0 asks for: every primitive is written here rather than taken from another project, so there is no third-party notice to preserve. The license of a published version cannot be changed after the fact, so it takes effect here.

    The honest limit of the first one is worth stating. What a unit test can prove is that a folder which does not match the package is not handed out — that is what the test and the mutation that takes the read-back out cover. Whether a given host can deliver a folder in part is not something a test reproduces: a rename that loses most of its contents is an operating-system event, not a code path. The guard is proven by taking it out; the event it guards against is argued from the cache that was found, not simulated.

    #What changed in 0.1.12

    • A bundle that cannot start says so. Through moonx there is no native launcher, so the application itself becomes the stub: the result holds the whole payload and cannot unpack itself. The warning that said so was printed before the summary, where a list of a few hundred checksums hides it, and the command still ended with bundled N files. It is the last line now, it names the file, it says that the file carries the payload but no launcher, and it goes to stderr as well as to stdout. This cost a real user an afternoon: a 124 MB file that held every dependency and could not start.
    • A bundle that is refused leaves nothing behind. The archive is written before the launcher is looked at, so a launcher that turns out to be missing left *.lunapack-archive.tmp in the directory that was asked for the bundle. The cleanup reaches the archive from the moment it is written.
    • The mutation battery runs on a Windows checkout again. Its anchors are written with LF endings and .gitattributes keeps a .ps1 on CRLF, so every anchor that spanned more than one line stopped matching and reported ANCHOR NOT FOUND — the battery quietly stopped proving anything. The comparison is made on one line ending now.

    Failing outright when no launcher is available was the other way to go, and it was not taken: the moonx flow is documented above, and a payload-only bundle is a real deliverable that unpack reads. What was wrong was not the capability but the way it reported itself. A bundle that is meant to be started needs --launcher.

    #What changed in 0.1.11

    • The native build builds on every platform again. The Windows-only GetModuleFileNameW declaration was guarded with target="native", which is true on Linux and macOS as well, so the linker looked for a Windows symbol there and the release workflow could not build those two binaries. The declaration and its caller are guarded with all(target="native", platform="windows") now; on other platforms the launcher keeps the argv[0] fallback it had before 0.1.10. The Windows build and the Wasm build were never affected, and the container layout does not change.

    Version 0.1.10 had already reached the registry when the release workflow reported this, and a published version cannot be replaced, so this is a patch release. moonx riantr/LunaPack@latest resolves to 0.1.11.

    #What changed in 0.1.10

    • The command that was typed is the command that runs. --input-file and --output-file are names for --input-exe and --output-exe now, and a flag the tool does not know is refused instead of being dropped. An empty output path is refused as well. Without those three changes an unknown flag could scan a whole directory and then fail with a rename to an empty file name — which is exactly what a real moonx riantr/LunaPack@latest run with the two aliases did.
    • A failure says that it failed. Every command that cannot do what it was asked now exits with 1 instead of 0. The VERIFY OK / VERIFY FAILED / error: ... markers on stdout stay, so a script that reads the output keeps working.
    • A check that was asked for is a check that is made. verify --extracted no longer lets an intact extracted directory hide a damaged package, and unpack honours --signature, --public-key and --expected-sha256 before it writes a single file.
    • On Windows the launcher knows which file it is. It locates itself with GetModuleFileNameW instead of argv[0], which a caller can forge. A bundle carries its own copy of the launcher, so a bundle made before this release has to be rebuilt to pick up the fix. On other platforms the launcher keeps the argv[0] fallback it had before.
    • A package cannot turn off its own integrity. A format 6 package whose integrity marker is missing or damaged is refused rather than downgraded to "legacy"; real format 5 packages are still read, with a warning. A non-PE file can no longer dictate the subsystem the launcher reads, because the PE signature and the header bounds are checked before the field is believed.
    • Malformed input is damage, not a crash. The LZ4 decoder checks its bounds, caps the expansion of a hostile frame and reads the DictID flag from the bit the format assigns to it. Windows install scripts get their bare file name, so cmd does not read the path as a switch, LUNAPACK_DEST is absolute, and an unreadable --pre-install script fails the command instead of discarding every other option.

    A deep QA pass found these defects. 127 tests pass on the native backend and on the Wasm backend now. The container layout does not change, so a package written by 0.1.9 is still read by 0.1.11, and the other way round.

    #What changed in 0.1.9

    • The public key of a pair holds both of its public keys. A pair serves two purposes — the key a package is sealed to, and the key a signature is checked with — and the file held only the first of them. keygen writes both now, and the two are derived from the secret with a label of their own, so the key that opens a package is not the key that signs one.
    • The signature of a package holds. In 0.1.8 the signature was checked against the encryption public key, so a signature made with the secret key of the pair never held: the feature was published and it did not work. The Linux end-to-end suite found it, and the chain that the command line walks is held by a unit test now, so it does not depend on a POSIX host to be checked.
    • A damaged package says that it is damaged. verify on a file that carries the marker of a package but is not one says so, and a file that carries no marker at all says that it is not a package. The two are not the same thing, and the wording keeps them apart.

    A public key file of the older form — the 32 raw bytes of the encryption key — still seals a package. It cannot be used to check a signature, and it says so.

    #What changed in 0.1.8

    • The manifest carries its own integrity. Every package now holds a block with the SHA-256 of its manifest, and a sealed package holds a MAC of it as well. A manifest that was changed is refused, and the reason is named.
    • A damaged package is refused instead of being taken for something else. A file that carries the marker of a package but is not one is an error, not a file that is not a package. A bundle that cannot be unpacked does not fall back to printing its usage.
    • A package is written in one piece. The package is written into a file of its own, flushed to the disk and only then does it take the place of the target. A target that was a whole package is still a whole package after a crash, and a file that is written is never half-written.
    • The cache of a bundle is checked on every start, and the program is checked before it runs. A payload that was changed after it was unpacked is unpacked again, and a program that does not match the package is not started.
    • The paths of the entries are checked. A path that walks out of the destination with .. is refused before anything is written.
    • Every checksum is computed twice, and the MD5 is computed as well, so that a fault in the machine is not mistaken for a fault in the package.
    • The host can sign a package. lunapack sign writes a detached Ed25519 signature next to the package, and lunapack verify --signature checks it. lunapack verify --expected-sha256 checks a digest the host published. Both are checked before the package is opened.
    • The Ed25519 implementation is new, and it is checked against vectors that another implementation produced, so a signature that is right here is right against every other implementation of RFC 8032.

    The published surface grows by the signature functions and the Ed25519 ones. The container layout does not change: the integrity block sits between the last entry and the manifest, so a package that carries one is still a package that an older LunaPack can read.

    #What changed in 0.1.7

    • The short coordinate runs. moonx riantr/LunaPack@latest resolves and starts the tool. The root package riantr/LunaPack is now the executable as well as the library, so the registry stores the prebuilt WebAssembly module under the module's own name and there is no second package to name. Before this release the same coordinate failed with "Prebuilt wasm asset does not exist", because the entry point lived in a second package.
    • The entry point left cmd/main. The CLI is cli.mbt in the root package and the cmd/ directory is gone. A consumer's import path does not change: import { "riantr/LunaPack" @lib } names the same API, and the generated pkg.generated.mbti is byte-identical to the one 0.1.6 published.
    • The price of the short coordinate is named, not hidden. A main package's blackbox tests are on a deprecation path and a dependency that imports a main package is deprecated. Both are described under Packages and targets, with the alternative that avoids them.

    #What changed in 0.1.6

    • There is a logo. LunaPack.png is what the README shows and what the registry's docs page renders; LunaPack_Logo.png is the same artwork at 1024×1024, for anyone who wants the full-resolution file. Both are published with the module, and the README's image path is relative, so the registry rewrites it to the asset URL.
    • A missing interpreter is named in the error. The runtime's spawn failure says "The system cannot find the file specified." and names no file, so a missing sh read like a defect. The error now says which interpreter was missing — cannot run the interpreter \sh`: …` — and the temporary script file is removed on that path too, instead of being left in the installation directory.
    • The README was restructured to the shape the sibling riantr/tesseract uses — title, logo, intro, Features, What you get, Install, then the technical sections, Quick Start, Example, Gates and drift detectors, Used By and Attribution — because a reader who has seen one of those modules should not have to learn a second layout. The technical content is the same content; the sections around it are new.

    The published surface grows by nothing: the two helpers that carry the change are private to the package, and run_install_script keeps its signature.

    #What changed in 0.1.5

    • The single file behaves like the program it carries. The arguments, the exit code, the subsystem and the standard streams of the bundle are the ones of the bundled program, so a windowed application does not flash a console window and a program that asks a question gets an answer.
    • The end-to-end suite covers the bundle subcommand and the named launcher. A bundle that is made with --launcher now starts with the launcher that was named — byte for byte — and a launcher that does not exist is refused instead of producing a bundle that cannot start.

    #Packages and targets

    PackageKindNativeWasm
    riantr/LunaPacklibrary and executableyesyes

    There is one package. It carries the whole public API and the entry point, which is what lets the coordinate be riantr/LunaPack@latest: the registry stores the prebuilt WebAssembly module per package, and the module's own name is the package's name.

    The package declares supported_targets = "native+wasm", and the module type-checks on both backends — moon check --target native and moon check--target wasm are exit 0 for each. There is no wasm-gc target and no js target: a check for those has nothing to do, because no package of the module claims one.

    The checks are not warning-free, and the numbers are in the open rather than in a footnote: --target native reports 2 warnings (a deprecated call in a test, and the one below) and --target wasm reports 6 (the same two, three in the Windows-only icon helpers, which the WebAssembly backend does not use, and one for the UTF-16 import that only the Windows native launcher needs). No gate runs with --deny-warn, and none of the warnings is an error.

    The second warning is the price of the short coordinate, and it is worth naming exactly: "Main package riantr/LunaPack uses blackbox-only test inputs (_test.mbt files) … Main packages will stop generating blackbox tests in a future release." The blackbox tests of the public API run today, and a toolchain that stops generating them would take that coverage with it. The alternative — a library in a non-main package under src/, with the root as the entry point — keeps the tests and the import path of the library safe, at the price of the library moving to riantr/LunaPack/src/lunapack.

    #Releases

    The binaries of a release are attached to its GitHub release page:

    AssetPlatform
    lunapack-windows-x86_64.exeWindows
    lunapack-linux-x86_64Linux
    lunapack-macos-aarch64macOS (Apple Silicon)
    lunapack.wasmWebAssembly, for moonx
    lunapack.corethe compiled package the WebAssembly module needs
    The release is built by a GitHub workflow when a version tag such as v0.1.14 is pushed; the same tag is published to the Mooncakes registry, so moonx riantr/LunaPack@latest resolves to it. The tag is the source of truth: the workflow compares it against the version in moon.mod before it builds anything, so a tag and the published module cannot disagree.

    The macOS runners are the scarce ones, and a tag that is pushed when none of them is free leaves the macOS binary unbuilt. The release is made anyway, with the binaries that were built, and its notes name the one that is missing. A release that says what it holds is worth more than one that waits for a runner that may not come.

    The release run shows red in that case, and the run is not the release: the job that publishes the release is green and the release page holds the binaries. The red comes from the macOS leg, which GitHub cancels when it has waited for a runner for a quarter of an hour. A run that is red because a runner was not free is a fact about the runners, not about the release.

    The registry refuses a version that already exists, and it wants a 0.x.y version while it is in its early stage, so the releases are numbered 0.1.0, 0.1.1, 0.1.2, 0.1.3, 0.1.4, 0.1.5 and so on.

    #Publishing to Mooncakes

    moonx resolves a package from the registry, so the module has to be published first. The release workflow does that with the registry token it holds as a secret; locally the commands are:

    moon login # once moon publish --dry-run # validate moon publish # publish riantr/LunaPack moonx riantr/LunaPack@latest --input-exe=... --output-exe=... --Files=...

    The module publishes with supported_targets = "native+wasm"; moonx runs the Wasm build, which reads and writes the real filesystem through the MoonBit runtime.

    #The bundle

    myapp.exe = [ LunaPack launcher ] + [ archive: myapp.exe, res/app.cfg, ... ]

    Starting it extracts the payload and launches the bundled program:

    $ ./myapp.exe LunaPack: starting D:/apps/.lunapack/9c445884d23653fb/myapp.exe

    The payload is cached in .lunapack/<hash>/ next to the executable, so the second start is immediate. Delete that folder to force a fresh extraction.

    Because the launcher does the extraction, the application finds its own dependencies (.dll, .json, assets) exactly like it would in its build folder — a plain copy of the executable would not run at all.

    #The launcher

    The launcher is the program that unpacks the payload and starts the program it carries. It has to run on the machine the bundle is started on, so it has to be built for that machine.

    By default the running LunaPack binary is used, which is the right choice for a bundle that is made for the machine that makes it. --launcher names a different one:

    # A bundle for Windows, made with the launcher of the release. lunapack bundle -i ./myapp.exe -o ./myapp.exe \ --Files=./build --launcher ./lunapack-windows-x86_64.exe

    This is what makes a bundle for another operating system possible: the launcher of that operating system is named, and the bundle runs there.

    The bundle is self-extracting when the launcher is a native LunaPack binary. The one that runs on this machine is used by default, so a native LunaPack always produces a self-extracting file. A bundle produced through moonx runs on WebAssembly and has no native binary of its own, so it needs one named with --launcher — see above.

    Without one, moonx still writes a file that holds the whole payload, and that file does not start on its own: starting it runs the bundled program with none of its files unpacked next to it, which for most programs means it fails at once. The two files look identical from the outside — same name, same size, a whole payload inside — so the last line that the command prints says which of the two was made, and it also goes to stderr. A bundle that is only meant to carry a payload can be unpacked with lunapack unpack; a bundle that is meant to be started needs the launcher.

    #A single file that behaves like the program

    The bundle is not only a container: it is the program, with the files it needs around it.

    • The arguments of the bundle are the arguments of the bundled program.
    • The exit code of the bundle is the exit code of the bundled program.
    • The bundle keeps the console subsystem of the launcher, because a program with no standard streams at all does not survive the start of the runtime on Windows. A bundle that was started by a double-click takes that console away before it starts a program that draws a window, so a windowed program is started without a console window standing next to it, and a console program keeps the window its output goes to.
    • The standard input, output and error are the ones of the bundle, so a program that asks a question gets an answer.

    $ ./myapp.exe --version myapp 2.6.0 $ echo $? 0

    #The icon

    When the application carries an icon, the bundle gets it as well, so Explorer shows the application's own icon instead of the launcher's:

    $ lunapack --input-exe=./myapp.exe --output-exe=./myapp.exe --Files=./build md5 6b1b1e1f0a2c3d4e5f60718293a4b5c6 myapp.exe ... scanned 392 files bundled 392 files -> ./myapp.exe (icon copied) output md5 0f1e2d3c4b5a69788796a5b4c3d2e1f0 myapp.exe

    This is a Windows feature: an icon is a Windows resource, so on Linux and macOS the bundle keeps the icon of the launcher. Use --Files with the application's own .ico file there, or install the icon separately.

    lunapack unpack recognises the appended archive automatically, so the same file can be verified and extracted again:

    lunapack verify myapp.exe lunapack info myapp.exe lunapack unpack -i myapp.exe -o ./out

    #The package

    #The format

    The container layout has not changed since v2: the marker names the layout, and the manifest carries its own format_version. The current manifest is version 6, which adds the integrity block. A package written by an older version still unpacks, because the fingerprint, the encryption and the integrity block are all optional.

    +--------------------+ offset 0 | magic "APPKGD02" | 8 bytes +--------------------+ offset 8 | entry payloads | stored back to back, raw, gzip, lz4 or sealed +--------------------+ | integrity block | 40 bytes, or 72 for a sealed package +--------------------+ | manifest (UTF-8 | | JSON) | +--------------------+ | magic "APPKGD02" | 8 bytes | manifest offset | 8 bytes, little-endian int64 | manifest length | 8 bytes, little-endian int64 +--------------------+ end of file

    A bundle is the same archive appended to an executable. The archive length is manifest_offset + manifest_length + 24, so the archive can be located from the tail of the file without scanning it.

    The integrity block sits between the last entry and the manifest, which keeps the layout above unchanged: a package that carries one is still a package that an older LunaPack can read, because the older reader steps over the block and finds the manifest where it expects it. The block holds a marker, the SHA-256 of the manifest and — for a sealed package — the MAC of the manifest. What it is for is described under The integrity of a package.

    The manifest records the package metadata plus one record per entry:

    { "format_version": 6, "name": "myapp", "version": "1.4.0", "os": "linux", "arch": "x86_64", "checksum_algo": "sha256", "compression": "auto", "created_at": "2026-11-04T04:10:20Z", "main_program": "myapp.exe", "pre_install": { "platform": "posix", "body": "#!/bin/sh\n..." }, "post_install": null, "entries": [ { "path": "res/app.cfg", "mode": 420, "size": 22500, "stored_size": 913, "codec": "gzip", "checksum": "0a77c24c45d0c61f906862c4706f5dd739166985e8fc5c6344dadd9220507b28", "md5": "1520e796ba25554696b3cef6e913cc90", "offset": 8 } ] }

    • size is the original file size, stored_size the space it occupies in the package.
    • checksum is the SHA-256 of the original content, so it validates the data the user actually gets, not the compressed form.
    • md5 is the MD5 fingerprint of the same content. It is a convenience for comparing against what the platform tooling shows, not the integrity guarantee.
    • mode holds UNIX permission bits (0o644 / 0o755); they are ignored on Windows.
    • main_program names the entry that is the application executable.
    • encryption says how the package is sealed, and is null when it is not sealed at all.

    #The integrity of a package

    A package is a file that anyone can change. The question is not whether the file can be changed — it can — but what a change gets you, and how much of it the reader notices. LunaPack answers that in layers, and each layer answers a different question.

    LayerWhat it isWhat it stops
    SHA-256 of every entryin the manifesta payload that was changed or corrupted
    MD5 of every entryin the manifestthe same, by a second and independent algorithm
    SHA-256 of the manifestin the integrity blocka manifest that was changed in place, a truncated write
    HMAC-SHA512 of the manifestin the integrity block of a sealed packagea manifest that was rewritten by whoever holds the file
    The cache is checked on every startin the launchera payload that was changed after it was unpacked
    The program is checked before it runsin the launchera program that was changed between the check and the start
    The cache is read back after it arrivesin the launchera payload that only part of a move delivered
    A damaged package is refusedeverywherea package that was cut short being taken for something else
    The paths of the entries are checkedeverywherea path that writes outside the destination
    The package is written in one pieceeverywherea target that is half a package after a crash
    The checksum is computed twicein every checkera fault in the machine that computes it
    The signature of the hostnext to the packagea package that was rewritten from top to bottom

    The honest limit of all of this is worth stating plainly. The manifest of an unencrypted package cannot be authenticated by the package itself. The digest that goes with it is not a secret, so whoever rewrites the file can compute a digest that matches the manifest they wrote. What the digest does catch is corruption, a write that was cut short and a manifest that was changed in place — which is what a transient fault does.

    Format 6 is the format that brought the integrity block, and a package that says it is format 6 must carry one. A damaged or deliberately broken marker is refused, not taken for a package of the old format: silently downgrading would let a one-byte change turn off the check altogether. Packages whose manifest says format 5 or less are the old ones; they are still read, with a warning that their manifest was not authenticated.

    For the case that nothing inside the file can settle, the host holds the material:

    # The host signs the package with its secret key. lunapack sign myapp.appkg --secret-key host.key --output myapp.appkg.sig # Whoever holds the public key checks the signature. lunapack verify myapp.appkg --signature myapp.appkg.sig --public-key host.pub

    The signature is an Ed25519 one (RFC 8032), so the public key is enough to check it and the secret key is enough to make it. The same check works with nothing but a digest, for a channel that publishes one:

    lunapack verify myapp.appkg --expected-sha256 <the digest the host published>

    A pair of keys serves two purposes, so the public key of a pair holds both of its public keys:

    lunapack-public-key v1 x25519 751d2616ba1be8341b35f6cf2d73978e2cccecd1decd0f898b421a99618bc35f ed25519 a8414c851959a5449aad50fe53b9d976028fd3122248cf87331bd0e0a8ea526e

    The x25519 line is the key a package is sealed to, and the ed25519 line is the key a signature is checked with. pack reads the first and verify reads the second. The two are derived from the same secret with a label of their own, so the key that opens a package is not the key that signs one, and a fault in one of them does not hand the other one over. A public key file of the older form holds the 32 raw bytes of the encryption key alone: it still seals a package, and it cannot be used to check a signature.

    The Ed25519 implementation in this module is checked against vectors that were produced by an implementation that is not this one, so a signature that is right here is right against every other implementation of RFC 8032.

    What the layers mean for the reader in practice:

    • A package that was damaged is refused, not taken for a file that is not a package. A bundle that cannot be unpacked is not a program without arguments.
    • A package whose manifest was changed is refused, with the reason named.
    • A sealed package is refused when the key is wrong, when the manifest was changed, and when a key is given for a package that says it is not sealed. The key is the claim of the reader that the package is encrypted to them, and a package that says the opposite has been changed.
    • A path in the manifest that walks out of the destination with .. is refused before anything is written.

    #Compression

    --compression-method picks the codec of the entries:

    MethodWhat it does
    nonestore the files as they are
    gzipgzip every entry
    lz4LZ4 every entry
    autogzip every entry, but keep the raw bytes when gzip would make the entry bigger (the default)

    LZ4 does no entropy coding at all, so it is much faster than gzip while producing a larger result. That is the trade to make when a package is built often and unpacked rarely. Measured on an application of 392 files and 332 MB:

    gzipLZ4
    packing39 s16 s
    unpacking21 s19 s
    size of the package139 MB175 MB

    The LZ4 implementation lives in this package, because the MoonBit toolchain ships none. What it produces is a standard .lz4 frame, so the lz4 tool decompresses it, and this package decompresses what that tool produces. The suite checks the frames against the values the format publishes; it does not run the lz4 tool.

    #Encryption

    --encryption-method seals the entries of a package. There are two methods:

    MethodWhat it does
    x25519the package is sealed to the owner of a public key; only the owner of the secret key can open it
    passphrasethe package is sealed with a passphrase

    The mechanism is the one of the reference implementation this package follows. A key is established with the recipient, HKDF-SHA512 turns it into the key of the package, and every entry is sealed on its own with ChaCha20-Poly1305 under a key and a nonce that are derived for it. The additional data of an entry holds its place in the package and its name, so that neither can be changed without the seal breaking. A check in the manifest tells a wrong key from a damaged package.

    The key exchange is the one part that is different. The reference uses the sntrup761 KEM, which the toolchain has no counterpart of, so this package uses X25519 — the curve that the reference itself carries as a part of its own sntrup761x25519-sha512 method. An ephemeral key is made for every package, so the same recipient gets a different shared secret for every package.

    # Make a pair of keys. The public key goes to the machine that seals the # packages, the secret key to the machine that opens them. lunapack keygen --public-key recipient.pub --secret-key recipient.key # Seal a package to the owner of the public key. lunapack pack -i ./app -o app.appkg \ --encryption-method x25519 --public-key recipient.pub # Open it again with the secret key. lunapack unpack -i app.appkg -o ./out --secret-key recipient.key # Or with a passphrase. lunapack pack -i ./app -o app.appkg \ --encryption-method passphrase --passphrase-file passphrase.txt lunapack unpack -i app.appkg -o ./out --passphrase-file passphrase.txt

    The public key of a pair holds both of its public keys, because a pair serves two purposes: the key a package is sealed to and the key a signature is checked with. The file is a text, so a person can read what it holds:

    lunapack-public-key v1 x25519 751d2616ba1be8341b35f6cf2d73978e2cccecd1decd0f898b421a99618bc35f ed25519 a8414c851959a5449aad50fe53b9d976028fd3122248cf87331bd0e0a8ea526e

    A secret key that a passphrase protects is the better form: the raw form leaves the secret key in clear text on disk, and anyone who reads it can open every package that was ever sealed for it.

    lunapack keygen --public-key recipient.pub --secret-key recipient.key \ --passphrase-file passphrase.txt

    The self-extracting bundle finds its key next to itself: the file <bundle>.key for a public key, the file <bundle>.pass for a passphrase. The environment variables LUNAPACK_SECRET_KEY and LUNAPACK_PASSPHRASE_FILE name the files instead.

    #Install scripts

    The script body is stored verbatim inside the manifest together with the platform it targets. The platform is inferred from the file extension:

    ExtensionTarget platform
    .cmd, .bat, .ps1windows
    .shposix (Linux + macOS)
    anything elseany

    At unpack time the script matching the host platform runs; a script for a different platform is skipped, so one package can safely be unpacked anywhere. On Windows the script runs through cmd /c, on Linux/macOS through sh.

    The script runs with the destination directory as its working directory and receives two environment variables:

    • LUNAPACK_DEST — absolute path of the destination directory
    • LUNAPACK_PHASE — pre_install or post_install

    Use --skip-scripts to unpack without running them.

    #Running it

    #Via moonx (published on Mooncakes)

    moonx riantr/LunaPack@latest \ --input-exe=[input-exe-path] \ --output-exe=[output-exe-path] \ --Files=[Pack-Folder]

    The coordinate names the package that holds the entry point, because the registry stores the prebuilt WebAssembly module per package. The entry point lives in the module's own package, so the coordinate is riantr/LunaPack@latest — the module name and the package name are the same string.

    FlagMeaning
    --input-exethe application executable to distribute
    --output-exethe produced single-file bundle
    --Filesfolder of files to bundle with it (repeatable)

    --input-file is another name for --input-exe, and --output-file for --output-exe. A flag that the tool does not know is refused with a message that names it and the flags that it does know, so a mistyped flag never turns into a command that quietly does something else. The form a Windows prompt tends to produce therefore works as written:

    moonx riantr/LunaPack@latest \ --input-file="x64/Release/App.exe" \ --output-file="App.exe" \ --Files="x64/Release"

    The result is a self-extracting bundle: the LunaPack launcher with the archive appended, exactly the trick a WinRAR or 7-Zip self-extracting archive uses.

    The bundled program runs with the cache directory as its working directory, not the directory the bundle was started from. That is what lets a program find the files that were packed next to it. A relative path given on the command line is therefore read relative to the cache directory; pass an absolute path if the caller's directory is what matters.

    #The subcommands

    lunapack pack -i <input_dir> -o <output.appkg> [options] lunapack bundle -i <exe> -o <output.exe> [--Files <dir>]... [options] lunapack unpack -i <package> -o <dest_dir> [--skip-scripts] lunapack verify <package> [--extracted <dir>] lunapack sign <package> --secret-key <file> [--output <file>] lunapack info <package> lunapack keygen --public-key <file> --secret-key <file>

    Flags accept both --flag=value and --flag value.

    verify checks the package itself. verify <package> --extracted <dir> also checks the files that are already in dir against the fingerprints of the package, which is what the self-extracting bundle uses its cache for. The two checks are separate and both of them have to hold: a package that is damaged is not made whole by an extracted directory that is intact.

    lunapack verify myapp.exe --extracted .lunapack\b7e21c9f4d0a8356

    verify <package> --signature <file> --public-key <file> checks the signature that the host published next to the package, and verify <package>--expected-sha256 <digest> checks the digest the host published. Both are checked before the package is opened, so a package that does not match the material of the host is never unpacked at all.

    unpack accepts the same two flags and honours them: a package whose signature does not hold, or whose digest is not the one that was expected, is refused before anything is written. A flag that is accepted and then ignored would be worse than one that is refused, because the user would believe the package was checked when it was not.

    sign is the other side of that: it signs a package with the secret key of the host and writes the signature next to it.

    lunapack sign myapp.appkg --secret-key host.key --output myapp.appkg.sig

    The signature file is a text, so a person can read what it says:

    lunapack-signature v1 algorithm ed25519 package 0f2a... <- the SHA-256 of the package manifest 8c41... <- the SHA-256 of the manifest signature 3b90... <- the Ed25519 signature of the two

    On a cache that is intact:

    verify: OK (392 files) verify: OK (392 files in .lunapack/b7e21c9f4d0a8356) VERIFY OK

    After a file was changed on the disk:

    verify: OK (392 files) verify: md5 mismatch for appsettings.json: expected 86ff00ef... got 3ddcb685... verify: FAILED (392 files checked) VERIFY FAILED

    The bundle checks every file it writes as it extracts it, so the cache is verified once when it is filled. The check above is for auditing a cache that has been on the disk for a while.

    #As a native binary

    moon build --release # -> _build/native/release/build/LunaPack.exe (every platform)

    The toolchain names the executable after the package, and calls it LunaPack.exe on every platform, Windows included, so the path is the same everywhere.

    #Options

    OptionMeaning
    --input-file <path>another name for --input-exe
    --output-file <path>another name for --output-exe
    --name <name>package name (default: input dir / exe name)
    --version <ver>package version (default: 0.1.0)
    --os <os>target OS tag (default: detected)
    --arch <arch>target arch tag (default: detected)
    --pre-install <file>script executed before extraction
    --post-install <file>script executed after extraction
    --compression-method <m>none, gzip, lz4 or auto (default: auto)
    --launcher <file>the launcher of the single file (default: this one)
    --encryption-method <m>x25519 or passphrase (default: none)
    --public-key <file>the public key of the recipient, or of the host that signed the package
    --secret-key <file>the secret key of the recipient
    --passphrase-file <file>the file that holds the passphrase
    --passphrase <text>the passphrase itself (visible in the process list)
    --skip-scriptsunpack without running the install scripts
    --extracted <dir>with verify: also check the files already in <dir>
    --signature <file>with verify or unpack: the signature the host published
    --expected-sha256 <hex>with verify or unpack: the digest the host published
    --output <file>with sign: where the signature is written

    #A worked example

    # Bundle an application and its resources into one self-extracting file. lunapack --input-exe=./build/myapp \ --output-exe=./dist/myapp \ --Files=./build # Or produce a plain archive instead. lunapack pack -i ./myapp -o myapp.appkg \ --name myapp --version 1.4.0 \ --pre-install setup.sh --post-install setup.sh lunapack verify myapp.appkg lunapack unpack -i myapp.appkg -o /opt/myapp

    Packing prints the MD5 fingerprint of every file it scans, and the fingerprint of the file it produces:

    md5 6f5902ac237024bdd0c176cb93063dc4 a.txt md5 3db2050fcf84bb631dcae417d3db518c sub/b.txt scanned 2 files packed 2 files -> myapp.appkg output md5 63da650c906fe3ab1446b121a4edd4ca myapp.appkg

    The fingerprint of the produced file changes from run to run, because the manifest records the time of creation.

    Unpacking reads every extracted file back and checks its fingerprint against the one that went into the package, so a file that changed on the way to the disk is reported:

    unpacked 2 files -> /opt/myapp md5 6f5902ac237024bdd0c176cb93063dc4 a.txt md5 3db2050fcf84bb631dcae417d3db518c sub/b.txt verified 2 files, 0 mismatches

    MD5 is not collision resistant, so it is only the fingerprint that can be compared with what the platform tooling shows; the integrity of the package rests on the SHA-256 checksum of every entry.

    On Windows, with the paths of a real .NET application:

    lunapack ^ --input-exe="D:/src/App/bin/Release/net10.0-windows/App.exe" ^ --output-exe="D:/dist/App.exe" ^ --Files="D:/src/App/bin/Release/net10.0-windows"

    The contents of a single --Files directory are stored at the root of the archive next to the main program, so the extracted application is directly runnable. With several --Files directories each one gets a subdirectory named after it. The main program is never stored twice, even when it also lives inside the --Files folder.

    #Verification

    moon fmt --check, the two moon check targets, moon test --target native and the two release builds are the baseline, and bash scripts/test_linux.sh is the end-to-end suite on top of it.

    GateWhat it checks
    moon fmt --checkthat the source is formatted the way the toolchain formats it
    moon check --target native / --target wasmthat the module type-checks on both backends it claims
    moon test --target nativethe format, the compression, the encryption, the primitives
    moon build --release --target native / --target wasmthat the binary and the WebAssembly module are produced
    moon info + git diff --exit-code -- '*.mbti'that the published interface is not stale
    bash scripts/test_linux.shthe round trip the unit tests do not cover

    Measured on this machine (Windows, moon 0.1.20261102): moon test --targetnative reports 131 tests, 131 passed, 0 failed, and --target wasm reports the same.

    One of those tests needs a POSIX sh, because it exercises the POSIX branch of the install-script runner on a Windows host. Without a shell on PATH it cannot run, and before 0.1.6 it failed with the runtime's "The system cannot find the file specified." — a message that names no file and reads like a defect. The error now names the interpreter that was missing. A host with Git for Windows' sh (or Cygwin's) on PATH runs the whole suite.

    The two tests that cover the error itself name an interpreter that cannot exist on any host, so they are the same everywhere: they check that the error names the interpreter, and that the temporary script file is removed again when the interpreter could not be started.

    CI runs on Linux, macOS and Windows, and the Windows runner has Git for Windows, whose sh is on PATH, so the whole suite runs there. The macOS leg is optional — a run that lands while no runner of that kind is free is cancelled without failing the run — so a green run is not a claim that the macOS leg was scheduled. The gap was a developer machine without a POSIX shell, not the code.

    The end-to-end suite needs a real POSIX host, because it runs the install scripts and checks the permission bits, so CI runs it on the Linux runner. It has 19 sections: the environment, the build, the unit tests, the fixture, packing, the manifest, the checksums, the unpack, the content integrity, the executable bit, compression, the bundle, the MD5 fingerprints, the LZ4 codec, the encryption, the corruption detection, the integrity of the manifest, the signature of the host and --skip-scripts. It compares the bundled executable with the original byte for byte.

    The unit tests cover the LZ4 implementation as well: the round trip for every shape of payload — empty, short, repetitive, random, a sentence, a sentence with other periods, and one that crosses the block boundary — the magic number of the frame, and the xxHash of the frame header against the value the reference implementation produces.

    There is no test that runs the lz4 tool: the frames are compared against the values the format publishes, not against the tool. That is a gap, not a claim of coverage, and it is named here rather than left implied.

    The tests of the encryption check every primitive against the vector that its standard publishes — FIPS 180-4 for SHA-512, RFC 4231 for HMAC, RFC 5869 for HKDF, RFC 8439 for ChaCha20-Poly1305, RFC 7748 for X25519 and RFC 8032 for Ed25519 — and the derivation against the reference implementation itself: the master key, the key and the nonce of a record, the check of the key and the sealed record are compared byte for byte with the values that the reference produces.

    #Every guard was taken out once

    A test that passes because the guard is not there says nothing, so every guard of the integrity stack was removed from the code once and the suite was run again. The test that covers the guard has to go red, for that reason alone, or the guard is not covered by a test at all.

    Guard that was removedThe test that went red
    the SHA-256 of the manifesta package whose manifest was changed is refused
    the MAC of the manifesta sealed package whose manifest was changed is refused
    the check of the cachethe cache is only used again when it holds what the package carries
    the check of the programthe program of a bundle is checked before it is started
    the refusal of a damaged packagea damaged package is not taken for a file that is not a package
    the write in one piecea pack that fails leaves the target alone
    the comparison of the two checksumsa checksum that is not the same twice is refused
    the check of the signaturea signature does not hold for another public key
    the check of the pathsa path that walks out of the destination is refused
    the signing key of a pairthe signing key of a pair checks a signature made with it
    the separation of the two rolesthe two roles of a pair are separated
    the cleanup of the archive of a refused bundlea refused bundle leaves nothing behind
    the warning that tells the two kinds of bundle apartthe two kinds of bundle are told apart
    the read-back of the cache after it arrivesa cache that does not hold the payload is not handed out
    the ownership of the consolethe console of the terminal is not taken away

    The battery that does this is scripts/mutation_battery.ps1. It restores the file it changed, so a run leaves the tree exactly as it was.

    One of the two guards of the sealed manifest needed a second look, and the reason is worth stating because it is the whole point of the design: a test that changes the manifest of a sealed package is caught by the digest first, so it proves nothing about the MAC. The test that covers the MAC therefore changes the manifest and recomputes the digest, which is exactly what an attacker who holds the file can do — and the MAC still catches it, because the MAC is keyed with the key of the package and the key is not in the package.

    The last two guards of the table are there because of a defect that the Linux end-to-end suite found in 0.1.8 and that the unit tests had not: keygen wrote the encryption public key, and the signature was checked against it, so a signature made with the secret key of the pair never held. The public key file holds both public keys now, and the chain that the command line walks — make a pair, sign a package, check the signature — is held by a unit test, so it no longer depends on a host with a POSIX shell to be checked.

    #What this is not

    • The exit code of a command says whether it worked: 0 when it did, 1 when it did not. The status is on stdout as well (VERIFY OK / VERIFY FAILED / error: ...), so a script that reads the output still works. A single-file bundle reports the exit code of the program it carries, exactly.
    • UNIX permission bits cannot be set on Windows, so they are only applied on POSIX hosts.
    • The checksum is computed over the original content; verify therefore has to decompress every entry.
    • The fingerprint of the produced file is computed while it is written, so the file is never read back for that. The launcher stub is the one exception: it is hashed after the icon was written into it, because writing the icon changes its bytes.
    • The big integers of the core are not constant time, so the X25519 of this package is not either. The control flow of the ladder does not depend on the secret, but the time of the arithmetic does. For the use here — a key exchange on the machine that holds the secret key — that is not the threat, but it is a reason to look elsewhere when it is.
    • The encryption protects the content of the entries. The names of the entries and their sizes are in the manifest, which is not encrypted.
    • The launcher stub is the LunaPack executable of the machine that built the bundle, so a bundle runs on the platform it was created on. Under moonx (Wasm) no native stub exists, so the bundle falls back to carrying the payload inside the application executable — such a bundle holds the data but does not extract itself.
    • The extraction cache lives next to the bundle (.lunapack/<hash>/). When that folder cannot be written, a temporary directory is used instead.
    • Copying the icon is Windows only, because an icon is a Windows resource.
    • Bundling writes the archive to a temporary file next to the output, so there must be enough free disk space for it.
    • The package is built on moonbitlang/core/async, which the toolchain ships as an experimental package. The MoonBit developers have confirmed that the stable releases will use it, so the package follows the toolchain rather than pinning an older release of the runtime.

    #Quick Start

    Add the module:

    moon add riantr/LunaPack

    Bundle an application and the files it needs into one self-extracting file:

    lunapack bundle -i ./build/myapp -o ./dist/myapp --Files ./build ./dist/myapp --version

    Or produce a plain archive and take it apart again:

    lunapack pack -i ./myapp -o myapp.appkg lunapack verify myapp.appkg lunapack unpack -i myapp.appkg -o /opt/myapp

    From this repository:

    moon test # 131 tests moon build --release --target native # the binary moon build --release --target wasm # the WebAssembly module

    #Example

    ///|
    async fn main {
    // A plain archive: a directory, compressed per entry, checksummed per entry.
    let opts = @lp.new_pack_options(
    "myapp", // the name of the package
    "1.4.0", // the version of the package
    "linux", // the os tag
    "x86_64", // the arch tag
    None, // the pre-install script
    None, // the post-install script
    "auto", // the compression
    )
    @lp.pack("./myapp", "myapp.appkg", opts)

    // The same application as a single self-extracting file. The launcher is the
    // running program itself, and the main program is recorded by the bundle.
    @lp.bundle("./build/myapp", "./dist/myapp", ["./build"], opts, "")

    // Read the manifest back, and check the package.
    let m = @lp.read_package_manifest("myapp.appkg")
    let ok = @lp.verify("myapp.appkg", None)
    @lp.unpack("myapp.appkg", "/opt/myapp", true, None)
    }

    The archive is found from the tail of the file, so the same three calls work on a bundle as on a plain package:

    ///|
    async fn main {
    match @lp.probe_archive("./dist/myapp") {
    Some((offset, manifest)) => {
    println("the archive starts at " + offset.to_string())
    println("it holds " + manifest.entries.length().to_string() + " entries")
    }
    None => println("this file carries no LunaPack archive")
    }
    }

    An encrypted package carries its own key material in the manifest, and the key is recovered from the secret key file or from the passphrase:

    ///|
    async fn main {
    let m = @lp.read_package_manifest("secret.appkg")
    match m.encryption {
    Some(info) => {
    let key = @lp.recover_encryption(info, "recipient.key", "")
    @lp.unpack("secret.appkg", "./out", true, Some(key))
    }
    None => ()
    }
    }

    #Gates and drift detectors

    moon fmt --check, the two moon check targets, moon test --target native and moon build --release are the baseline. Each of them catches a specific drift, because a green suite says nothing about a claim written in prose:

    GateCatches
    moon fmt --checka source file that is not formatted the way the toolchain formats it — a diff the next contributor would have to make
    moon check --target native / --target wasma package that does not type-check on one of the two backends it claims to support
    moon test --target nativea change in the format, the compression, the encryption or one of the primitives — every one of them is checked against a vector or against the reference implementation
    moon info + git diff --exit-code -- '*.mbti'a published interface that is stale: a symbol changed and the .mbti was not regenerated
    bash scripts/test_linux.sha round trip the unit tests do not cover — the install scripts, the permission bits, the corruption detection, the bundle as a byte-identical round trip
    the tag check of the release workflowa tag that disagrees with the version in moon.mod, which would publish a module that is not the tree it was cut from

    There are no separate drift detectors here. The gates above are the whole gate, and the numbers in this file come from running them by hand. A gate that only runs when someone remembers it is a gate that drifts, and that gap is worth closing rather than papering over.

    Which of them CI runs. The CI workflow (.github/workflows/ci.yml) runs moon check --target native, moon test --target native, both release builds, bash scripts/test_linux.sh on the Linux runner, moon fmt --check and the moon info diff. The moon check --target wasm and the moon test on the Wasm backend are not in CI: they are listed here because they were run by hand, not because CI depends on them.

    #Used By

    • cli.mbt — the command line of this module, the front end over the library that lives in the same package.
    • integrity.mbt — the integrity block: the digest and the MAC of the manifest, and the checksums that are computed twice.
    • ed25519.mbt — the signature that the host publishes next to a package.
    • signature.mbt — the signature file and the checks of the host material.
    • scripts/test_linux.sh — the end-to-end suite, which drives the binary that the module builds.
    • scripts/mutation_battery.ps1 — the development tool that takes every guard out of the code once and checks that the test covering it goes red.

    Outside this repository: none that the registry knows about. The registry lists 0 dependents for riantr/LunaPack, and nothing is claimed here that cannot be named.

    #Attribution

    • License: MIT (see LICENSE).
    • The mechanism of the encryption and the shape of the container follow the reference implementation this package follows. Every primitive it needs is implemented in this package, because the MoonBit toolchain ships none of them, and each one is checked against the vector that its standard publishes.
    • The LZ4 codec is an implementation of the format, not a port of a library: what it produces is a standard .lz4 frame, and the test suite checks the frames against the values the format publishes.
    • The Ed25519 signature is an implementation of RFC 8032, not a port of a library. It is checked against vectors that another implementation produced, so a signature that is right here is right against every other implementation of the standard.

    ActiveEncryption

    pub struct ActiveEncryption {
    key : PackageKey
    info : Encryption
    }

    The encryption of a package that is being packed.

    Archive

    type Archive

    The archive of a package: where it is and what it says.

    Encryption

    pub struct Encryption {
    method_name : String
    recipient : String
    ephemeral : String
    salt : String
    iterations : Int
    key_check : String
    }

    How a package is encrypted.

    EncryptionOptions

    pub struct EncryptionOptions {
    method_name : String
    public_key : String
    passphrase : String
    iterations : Int
    }

    How a package is encrypted when it is packed.

    Entry

    pub struct Entry {
    path : String
    mode : Int
    size : Int64
    stored_size : Int64
    codec : String
    checksum : String
    md5 : String
    offset : Int64
    }

    One file inside the package.

    Integrity

    type Integrity

    The integrity block of a package.

    Manifest

    pub struct Manifest {
    format_version : Int
    name : String
    version : String
    os : String
    arch : String
    checksum_algo : String
    compression : String
    created_at : String
    main_program : String
    pre_install : Script?
    post_install : Script?
    encryption : Encryption?
    entries : Array[Entry]
    }

    Package metadata plus the list of entries.

    Md5

    pub struct Md5 {
    state : Array[UInt64]
    pending : Array[Byte]
    length : Int64
    }

    The state of an MD5 that is fed in pieces.

    A file that is written while it is hashed does not have to be held in memory, and it never has to be read back to be hashed.

    Md5::Md5

    fn Md5::Md5() -> Md5

    A fresh MD5 computation.

    Md5::finish

    fn Md5::finish(self : Md5) -> String

    The digest of everything that was fed in.

    Md5::update

    fn Md5::update(self : Md5, data : Bytes) -> Unit

    Feed data into the computation.

    PackOptions

    pub struct PackOptions {
    name : String
    version : String
    os : String
    arch : String
    pre_install : Script?
    post_install : Script?
    compression : String
    main_program : String
    encryption : EncryptionOptions?
    }

    Options accepted by pack.

    PackageKey

    pub struct PackageKey {
    master : Bytes
    salt : Bytes
    }

    The key that protects the entries of a package.

    It is derived from the shared secret of the key exchange and never goes into the package.

    PackageSignature

    pub struct PackageSignature {
    package_digest : String
    manifest_digest : String
    signature : String
    }

    A detached signature of a package.

    PeFile

    pub struct PeFile {
    data : Bytes
    sections : Array[(Int64, Int64, Int64, Int64)]
    resource_rva : Int64
    resource_size : Int64
    }

    A parsed PE image.

    Poly1305

    pub struct Poly1305 {
    r0 : UInt64
    r1 : UInt64
    r2 : UInt64
    r3 : UInt64
    r4 : UInt64
    s0 : UInt64
    s1 : UInt64
    s2 : UInt64
    s3 : UInt64
    h0 : UInt64
    h1 : UInt64
    h2 : UInt64
    h3 : UInt64
    h4 : UInt64
    buffer : Array[Byte]
    }

    Poly1305 (RFC 8439), the one-time authenticator.

    The accumulator is kept as five limbs of 26 bits, which is the shape the reference implementation uses.

    Poly1305::Poly1305

    fn Poly1305::Poly1305(key : Bytes) -> Poly1305

    A fresh Poly1305 computation under the 32-byte key.

    Poly1305::finish

    fn Poly1305::finish(self : Poly1305) -> Bytes

    The tag of everything that was fed in: 16 bytes.

    Poly1305::update

    fn Poly1305::update(self : Poly1305, data : BytesView) -> Unit

    Feed data into the computation.

    PublicKeyPair

    pub struct PublicKeyPair {
    encryption : Bytes
    signing : Bytes
    }

    The two public keys of a pair.

    Script

    pub struct Script {
    platform : String
    body : String
    }

    An install script together with the platform it targets.

    A package built on Windows can carry a Windows batch script while a package built on Linux carries a POSIX shell script; the matching one is executed at unpack time and the other is skipped.

    SealedPackage

    pub struct SealedPackage {
    shared : Bytes
    ephemeral : Bytes
    recipient : Bytes
    salt : Bytes
    }

    The shared secret of a package that is sealed to the owner of the public key recipient, together with the public numbers of the package.

    Sha512

    pub struct Sha512 {
    state : Array[UInt64]
    pending : Array[Byte]
    length : UInt64
    }

    The state of a SHA-512 that is fed in pieces.

    Sha512::Sha512

    fn Sha512::Sha512() -> Sha512

    A fresh SHA-512 computation.

    Sha512::finish

    fn Sha512::finish(self : Sha512) -> Bytes

    The digest of everything that was fed in: 64 raw bytes.

    Sha512::update

    fn Sha512::update(self : Sha512, data : Bytes) -> Unit

    Feed data into the computation.

    CHECKSUM_ALGO

    let CHECKSUM_ALGO : String

    Checksum algorithm used for every entry payload.

    FORMAT_VERSION

    let FORMAT_VERSION : Int

    Format version recorded in the manifest.

    v3 adds the MD5 fingerprint of every entry next to its SHA-256 checksum. v4 adds the LZ4 codec. v5 adds the encryption of the entries. v6 adds the integrity block: a digest of the manifest that says that the manifest is the one the packer wrote, and, for an encrypted package, a MAC of the manifest that says that nobody changed it since.

    GZIP_LEVEL

    let GZIP_LEVEL : Int

    The compression level that the entries use.

    Level 1 is the fastest setting of the deflate encoder, and it is also the one that produces the smallest packages here: measured on an application of 392 files it compresses faster, decompresses faster and yields a package that is about a quarter smaller than the stream implementation did.

    INTEGRITY_BLOCK_BYTES

    let INTEGRITY_BLOCK_BYTES : Int

    The number of bytes of the integrity block of a package that is not encrypted: the marker and the digest.

    INTEGRITY_BLOCK_BYTES_ENCRYPTED

    let INTEGRITY_BLOCK_BYTES_ENCRYPTED : Int

    The number of bytes of the integrity block of an encrypted package: the marker, the digest and the MAC.

    INTEGRITY_DIGEST_BYTES

    let INTEGRITY_DIGEST_BYTES : Int

    The number of bytes of the digest of the manifest.

    INTEGRITY_MAC_BYTES

    let INTEGRITY_MAC_BYTES : Int

    The number of bytes of the MAC of the manifest.

    INTEGRITY_MARKER

    let INTEGRITY_MARKER : String

    The marker of the integrity block of a package that is not encrypted.

    INTEGRITY_MARKER_BYTES

    let INTEGRITY_MARKER_BYTES : Int

    The number of bytes of the marker of the integrity block.

    INTEGRITY_MARKER_ENCRYPTED

    let INTEGRITY_MARKER_ENCRYPTED : String

    The marker of the integrity block of an encrypted package.

    KEY_ALGORITHM

    let KEY_ALGORITHM : Int

    The algorithm of the protected key file format.

    KEY_BYTES

    let KEY_BYTES : Int

    The number of bytes of a key.

    KEY_HEADER_BYTES

    let KEY_HEADER_BYTES : Int

    The number of bytes of the header of the protected key file.

    KEY_MAGIC

    let KEY_MAGIC : String

    The magic of a protected key file.

    KEY_SALT_BYTES

    let KEY_SALT_BYTES : Int

    The number of bytes of the salt of the protected key file.

    KEY_VERSION

    let KEY_VERSION : Int

    The version of the protected key file format.

    LZ4_BLOCK_SIZE

    let LZ4_BLOCK_SIZE : Int

    The size of the blocks that a frame is cut into.

    LZ4_MAGIC

    let LZ4_MAGIC : UInt

    The magic number that starts every LZ4 frame.

    MAGIC

    let MAGIC : String

    Magic marker at the head and inside the footer of every .appkg file. v2 = SHA-256 checksums + optional gzip compression + install scripts.

    The marker names the container layout, which did not change when the MD5 fingerprint was added: the fingerprint is stored in the manifest and is optional, so a package written by an older version still unpacks and a newer version still reads an older package.

    METHOD_PASSPHRASE

    let METHOD_PASSPHRASE : String

    The method that seals a package with a passphrase.

    METHOD_X25519

    let METHOD_X25519 : String

    The method that seals a package to the owner of a public key.

    NONCE_BYTES

    let NONCE_BYTES : Int

    The number of bytes of the nonce of an entry.

    PASSPHRASE_ITERATIONS

    let PASSPHRASE_ITERATIONS : Int

    The number of iterations of the passphrase derivation.

    PE_SUBSYSTEM_CUI

    let PE_SUBSYSTEM_CUI : Int

    The subsystem of a console application.

    PE_SUBSYSTEM_GUI

    let PE_SUBSYSTEM_GUI : Int

    The subsystem of a windowed application.

    PE_SUBSYSTEM_OFFSET

    let PE_SUBSYSTEM_OFFSET : Int64

    The offset of the subsystem field in the optional header of a PE image.

    The value is at offset 68 of the optional header, in PE32 as well as in PE32+. 2 means a windowed application, 3 a console one.

    PUBLIC_KEY_ENCRYPTION

    let PUBLIC_KEY_ENCRYPTION : String

    The name of the encryption public key in a public key file.

    PUBLIC_KEY_HEADER

    let PUBLIC_KEY_HEADER : String

    The first line of a public key file.

    PUBLIC_KEY_SIGNING

    let PUBLIC_KEY_SIGNING : String

    The name of the signing public key in a public key file.

    RT_GROUP_ICON

    let RT_GROUP_ICON : Int64

    RT_ICON

    let RT_ICON : Int64

    Resource type ids used by the icon resources.

    SALT_BYTES

    let SALT_BYTES : Int

    The number of bytes of the salt of the key derivation.

    SHA512_BYTES

    let SHA512_BYTES : Int

    The length of a SHA-512 digest.

    SIGNATURE_ALGORITHM

    let SIGNATURE_ALGORITHM : String

    The algorithm of a signature file.

    SIGNATURE_HEADER

    let SIGNATURE_HEADER : String

    The first line of a signature file.

    TAG_BYTES

    let TAG_BYTES : Int

    The length of the tag of the authenticated encryption.

    basename

    fn basename(p : String) -> String

    The base name of a path, without any directory part.

    bundle

    async fn bundle(input_exe : String, output_exe : String, file_dirs : Array[String], opts : PackOptions, launcher : String) -> Unit

    Bundle input_exe together with the contents of every directory in file_dirs into the single file output_exe.

    The result is a launcher with a LunaPack archive appended, so it stays a runnable executable while carrying the extra files. Starting it unpacks the archive and starts the program it carries. Use unpack on the result to extract the payload again.

    The contents of a single --Files directory land at the root of the archive, next to the main program, so an extracted application is directly runnable. With several directories each one gets a subdirectory named after it, to keep the paths unique.

    launcher names the executable that the archive is appended to. It is the running program itself when it is empty, which is the right choice when the bundle is made for the machine that makes it. A different one is needed when the bundle is made for another machine: the launcher has to run there, so it has to be built for there.

    bytes_concat

    fn bytes_concat(a : Bytes, b : Bytes) -> Bytes

    The bytes of two buffers, one after the other.

    bytes_to_hex

    fn bytes_to_hex(data : Bytes) -> String

    Lowercase hex of data.

    chacha20_xor

    fn chacha20_xor(key : Bytes, nonce : Bytes, counter : UInt, data : BytesView) -> Bytes

    The ChaCha20 encryption of data: the keystream of the key and the nonce, starting at counter, exclusive-ored with the data.

    chacha20poly1305_open

    fn chacha20poly1305_open(key : Bytes, nonce : Bytes, sealed_data : BytesView, aad : BytesView) -> Bytes?

    Open a ciphertext that was sealed with ChaCha20-Poly1305.

    The sealed data is the ciphertext followed by the 16-byte tag. Returns None when the tag does not match, which means that the data was damaged or that the key is wrong.

    chacha20poly1305_seal

    fn chacha20poly1305_seal(key : Bytes, nonce : Bytes, plaintext : BytesView, aad : BytesView) -> Bytes

    Seal plaintext with ChaCha20-Poly1305.

    The additional data is authenticated but not encrypted. The result is the ciphertext followed by the 16-byte tag.

    collect_files

    async fn collect_files(root : String) -> Array[String]

    Collect every regular file under root, recursively.

    compress_with

    fn compress_with(data : Bytes, codec_name : String) -> (Bytes, String)

    Compress data with the method that method names.

    The codec is one of "none", "gzip", "lz4" or "auto". "auto" keeps the raw bytes when the compression would make the entry bigger, which is what happens with the small files that do not compress at all.

    Returns the payload to store plus the codec name to record in the manifest.

    compute_key_check

    fn compute_key_check(shared : Bytes, recipient : Bytes, ephemeral : Bytes, salt : Bytes) -> Bytes

    The check that tells a wrong key from a damaged package.

    It is a truncated HMAC-SHA512 over the shared secret and the public numbers of the package, so it holds even for a package that carries no entry at all.

    copy_icon

    async fn copy_icon(source_exe : String, target_exe : String) -> Bool

    Copy the icon of source_exe into target_exe.

    The icon is a Windows resource, so this is a no-op elsewhere.

    current_exe_path

    fn current_exe_path() -> String?

    Path of the running executable, when it can be determined.

    The path of the running image is asked of the operating system, never taken from argv[0]: the first argument of a process is a string that whoever started it chose, and a program that reads its own archive from it can be made to run an archive of somebody else's. CreateProcess takes the command line and the application name apart, so the two can name different files.

    default_arch

    fn default_arch() -> String

    Best-effort detection of the host CPU architecture. Windows exposes PROCESSOR_ARCHITECTURE; some POSIX shells export HOSTTYPE.

    derive_package_key

    fn derive_package_key(shared : Bytes, salt : Bytes) -> PackageKey

    Derive the key that protects the entries of a package.

    The input is the shared secret of the key exchange, or the result of the derivation of a passphrase. The result never goes into the package.

    dirname

    fn dirname(p : String) -> String

    The directory part of a path, or "" when the path has no directory.

    ed25519_public_key

    fn ed25519_public_key(secret : Bytes) -> Bytes raise

    The public key of the secret key secret: 32 bytes.

    The secret key is 32 random bytes. They are hashed with SHA-512, the lower half is clamped into the scalar and the upper half is the prefix that the signature uses.

    ed25519_seed

    fn ed25519_seed(secret : Bytes) -> Bytes

    The seed of the signing key of the secret key secret.

    The seed is derived with HKDF-SHA512 and a label of its own, so the key that signs is not the key that decrypts: the two roles cannot be taken for one another, and a fault in one of them does not hand the other over.

    ed25519_sign

    fn ed25519_sign(secret : Bytes, message : Bytes) -> Bytes raise

    The signature of message with the secret key secret: 64 bytes.

    The signature is the encoded point R followed by the scalar S, as RFC 8032 says.

    ed25519_verify

    fn ed25519_verify(public : Bytes, message : Bytes, signature : Bytes) -> Bool raise

    Does the signature of message hold for the public key public?

    The signature is checked against the group equation of RFC 8032, and a signature whose scalar is not reduced is refused: such a signature is one of the many ways to write the same signature, and accepting it would make the signature malleable.

    ensure_parent_dir

    async fn ensure_parent_dir(path : String) -> Unit

    Create the parent directory of path when it does not exist yet.

    establish_encryption

    async fn establish_encryption(opts : EncryptionOptions) -> ActiveEncryption

    Establish the encryption of a package that is packed.

    The key exchange happens here, before the first entry is written, because the place of an entry in the package is part of its seal.

    exit_with

    fn exit_with(code : Int) -> Unit

    End the process with code.

    extract_icon

    fn extract_icon(pe : PeFile) -> (Int64, Bytes, Array[(Int64, Bytes)])? raise

    The icon of a PE image.

    Returns the id of the group icon, the group icon itself and every icon it references, or None when the image carries no icon.

    extract_to

    async fn extract_to(pkg : String, dest : String, key : PackageKey?) -> Unit

    Extract the payload of pkg into dest silently.

    Used by the self-extracting launcher, which does not want to list every file it writes.

    file_md5_hex

    async fn file_md5_hex(path : String) -> String

    Lowercase hex MD5 of the content of the file at path.

    The file is read as a whole, so it needs to fit in memory; that is the same limitation the packing code has.

    find_resource

    fn find_resource(pe : PeFile, rtype : Int64, name : Int64, lang : Int64) -> (Int64, Int64)? raise

    The payload of a resource, as (offset in the file, size in bytes).

    rtype and name are the numeric resource type and name, lang the language id; use 0 for the first match and a negative name to accept the first entry of that type.

    gzip_compress

    fn gzip_compress(data : Bytes) -> Bytes

    Compress data with gzip.

    gzip_decompress

    fn gzip_decompress(data : Bytes) -> Bytes

    Decompress gzip-compressed data.

    A stream that cannot be decompressed is returned as it is, so that the checksum of the entry reports the damage instead of the program stopping with an error.

    hex_to_bytes

    fn hex_to_bytes(text : String) -> Bytes?

    Decode a hex string.

    Returns None when the text is not a hex string of even length.

    hkdf_expand_sha512

    fn hkdf_expand_sha512(prk : Bytes, info : Bytes, length : Int) -> Bytes

    The HKDF expand step of RFC 5869: OKM = T(1) || T(2) || ...

    T(0) is empty and T(i) = HMAC-Hash(PRK, T(i-1) || info || i), where the counter is a single byte.

    hkdf_extract_sha512

    fn hkdf_extract_sha512(salt : Bytes, ikm : Bytes) -> Bytes

    The HKDF extract step of RFC 5869: PRK = HMAC-Hash(salt, IKM).

    hkdf_sha512

    fn hkdf_sha512(ikm : Bytes, salt : Bytes, info : Bytes, length : Int) -> Bytes

    HKDF-SHA512: the extract step followed by the expand step.

    A salt of zero length is the same as no salt at all: RFC 5869 says that a string of HashLen zeros is used then.

    hmac_sha512

    fn hmac_sha512(key : Bytes, data : Bytes) -> Bytes

    HMAC-SHA512 (RFC 2104) of data under key: 64 raw bytes.

    A key that is longer than the block size is hashed first, a key that is shorter is padded with zeros.

    host_platform

    fn host_platform() -> String

    Name of the platform the program is running on.

    One of "windows", "linux", "macos" or "unknown".

    i64_to_le8

    fn i64_to_le8(v : Int64) -> Bytes

    8-byte little-endian encoding of an Int64 into Bytes.

    integrity_block

    fn integrity_block(moff : Int64, mlen : Int64, mbytes : Bytes) -> Bytes raise

    The integrity block of a package that is not encrypted: the marker and the digest of the manifest.

    integrity_block_encrypted

    fn integrity_block_encrypted(key : PackageKey, moff : Int64, mlen : Int64, mbytes : Bytes) -> Bytes raise

    The integrity block of an encrypted package: the marker, the digest of the manifest and the MAC of the manifest.

    is_all_zero

    fn is_all_zero(data : Bytes) -> Bool

    Is the shared secret all zeros?

    A shared secret of all zeros means that the peer sent a point of a small order, which RFC 7748 section 6.1 says to refuse.

    is_integrity_block

    fn is_integrity_block(block : Bytes) -> Bool

    Is the block the integrity block of a package that is not encrypted?

    is_integrity_block_encrypted

    fn is_integrity_block_encrypted(block : Bytes) -> Bool

    Is the block the integrity block of an encrypted package?

    is_native_target

    fn is_native_target() -> Bool

    Is this build a native (directly runnable) target?

    is_protected_key_file

    async fn is_protected_key_file(path : String) -> Bool

    Is the file at path a key file that a passphrase protects?

    iso8601_utc

    fn iso8601_utc(epoch_millis : UInt64) -> String

    Format epoch milliseconds as an ISO-8601 UTC timestamp, e.g. 2026-11-03T14:35:55Z. (@env.now() reports milliseconds.)

    json_to_manifest

    fn json_to_manifest(text : String) -> Manifest raise

    Parse the manifest JSON back into a Manifest.

    le8_to_i64

    fn le8_to_i64(b : Bytes, start : Int) -> Int64

    Read an 8-byte little-endian Int64 from b starting at byte start.

    load_public_key

    async fn load_public_key(path : String) -> Bytes

    Read the encryption public key from path.

    A file that holds the two public keys of a pair gives the X25519 one, and a file that holds the 32 raw bytes of an older pair gives those: the encryption public key is the same in both.

    load_secret_key

    async fn load_secret_key(path : String, passphrase : String) -> Bytes

    Read the secret key of a pair from path.

    A file that a passphrase protects needs the passphrase; a file that holds the raw key is read as it is, and the passphrase is not used.

    load_signing_public_key

    async fn load_signing_public_key(path : String) -> Bytes

    Read the signing public key from path.

    It is the Ed25519 public key of the pair: the one a signature of a package is checked with. A file of the older form holds the encryption public key alone, so it cannot be used here.

    lz4_compress_frame

    fn lz4_compress_frame(data : Bytes) -> Bytes

    Compress data into a standard LZ4 frame.

    lz4_decompress_frame

    fn lz4_decompress_frame(src : Bytes, size : Int) -> Bytes

    Decompress the LZ4 frame in src.

    size is the number of bytes the frame holds once it is decompressed; the frame format does not carry it, so the caller has to know it.

    magic_bytes

    fn magic_bytes() -> Bytes

    The 8 magic bytes that open the package and close the footer.

    manifest_digest

    fn manifest_digest(moff : Int64, mlen : Int64, mbytes : Bytes) -> Bytes raise

    The digest of the manifest: the SHA-256 of the manifest and of the two numbers that place it.

    manifest_mac

    fn manifest_mac(key : PackageKey, moff : Int64, mlen : Int64, mbytes : Bytes) -> Bytes

    The MAC of the manifest, keyed with the key of the package.

    It is the truncated HMAC-SHA512 of the manifest and of the two numbers that place it, under a key that is derived from the key of the package.

    manifest_mac_key

    fn manifest_mac_key(key : PackageKey) -> Bytes

    The key of the MAC of the manifest, derived from the key of the package.

    The derivation uses the salt of the package and a label of its own, so that the MAC key cannot be used for anything else.

    manifest_to_json

    fn manifest_to_json(m : Manifest) -> String

    Serialize a manifest into the canonical UTF-8 JSON representation.

    md5_hex

    fn md5_hex(data : Bytes) -> String

    Lowercase hex MD5 digest of data (32 characters).

    md5_hex_of_string

    fn md5_hex_of_string(s : String) -> String

    Lowercase hex MD5 of the UTF-8 encoding of s.

    new_encryption

    fn new_encryption(method_name : String, recipient : String, ephemeral : String, salt : String, iterations : Int, key_check : String) -> Encryption

    Constructor so callers in other packages can build an Encryption.

    new_encryption_options

    fn new_encryption_options(method_name : String, public_key : String, passphrase : String, iterations : Int) -> EncryptionOptions

    Constructor so callers in other packages can build EncryptionOptions.

    new_pack_options

    fn new_pack_options(name : String, version : String, os : String, arch : String, pre_install : Script?, post_install : Script?, compression : String) -> PackOptions

    Constructor so callers in other packages can build PackOptions without relying on cross-package struct literals (which MoonBit disallows).

    new_package_key

    fn new_package_key(master : Bytes, salt : Bytes) -> PackageKey

    Constructor so callers in other packages can build a PackageKey.

    new_package_signature

    fn new_package_signature(package_digest : String, manifest_digest : String, signature : String) -> PackageSignature

    Constructor so callers in other packages can build a PackageSignature.

    new_script

    fn new_script(platform : String, body : String) -> Script

    Constructor so other packages can build a Script.

    new_sealed_package

    fn new_sealed_package(shared : Bytes, ephemeral : Bytes, recipient : Bytes, salt : Bytes) -> SealedPackage

    Constructor so callers in other packages can build a SealedPackage.

    norm_sep

    fn norm_sep(p : String) -> String

    Normalize path separators to '/' so host FS calls work uniformly on Windows (which returns '' from realpath) and inside the package format.

    not_self_extracting_message

    fn not_self_extracting_message(output : String) -> String

    The message that says that a bundle cannot start on its own.

    A bundle that carries no launcher holds the payload but nothing that unpacks it, so starting it runs the bundled program with none of its files next to it. That is not a bundle that "was made": it is a file that looks exactly like one and does not work, and the message is the whole of what tells the two apart.

    open_entry

    fn open_entry(key : PackageKey, index : Int, path : String, stored : Bytes) -> Bytes?

    Open the payload of the entry with the given index.

    Returns None when the seal does not hold, which means that the key is wrong or that the package was damaged.

    pack

    async fn pack(input : String, output : String, opts : PackOptions) -> Unit

    Bundle every file under input into a .appkg file at output.

    package_manifest_digest

    async fn package_manifest_digest(pkg : String) -> String

    The SHA-256 of the manifest of a package, hex.

    package_tag

    fn package_tag(m : Manifest) -> String

    Short, stable identifier of a package, used to name the cache folder.

    package_tag_of

    fn package_tag_of(text : String) -> String

    Short, stable identifier for arbitrary text.

    parse_pe

    fn parse_pe(data : Bytes) -> PeFile raise

    Parse the headers of a PE image.

    passphrase_secret

    fn passphrase_secret(passphrase : String, salt : Bytes, iterations : Int) -> Bytes

    The shared secret of a package that is sealed with a passphrase.

    pbkdf2_sha512

    fn pbkdf2_sha512(password : Bytes, salt : Bytes, iterations : Int, length : Int) -> Bytes

    PBKDF2-HMAC-SHA512 (RFC 8018): a key of length bytes from a passphrase.

    The result is the concatenation of T(1) || T(2) || ..., where T(i) = U(1) xor U(2) xor ... xor U(iterations) and U(1) = HMAC(password, salt || INT_BE32(i)) and U(j) = HMAC(password, U(j-1)).

    pe_subsystem

    fn pe_subsystem(data : Bytes) -> Int

    The subsystem of a PE image, or 0 when it is not one.

    The value decides whether Windows opens a console window for the process, so a bundle has to carry the subsystem of the program it carries: a windowed program that is bundled must not flash a console window when it is started.

    pe_subsystem_of

    async fn pe_subsystem_of(path : String) -> Int

    The subsystem of the PE image at path, or 0 when it is not one.

    poly1305

    fn poly1305(key : Bytes, data : BytesView) -> Bytes

    The tag of data under the 32-byte key.

    probe_archive

    async fn probe_archive(path : String) -> (Int64, Manifest)?

    Locate the archive inside path, when there is one.

    Returns the offset of the archive and its manifest, or None when the file carries no LunaPack archive at all.

    A file that carries the marker of a LunaPack file but is damaged is an error and not None: a damaged package is not the same thing as a file that is not a package, and a caller that cannot tell the two apart would take a damaged package for something else.

    public_key_pair

    fn public_key_pair(secret : Bytes) -> PublicKeyPair raise

    The two public keys of the secret key secret.

    random_bytes

    fn random_bytes(count : Int) -> Bytes

    Random bytes from the generator of the core.

    read_at

    fn read_at(pe : PeFile, off : Int64, size : Int64) -> Bytes

    Read size bytes at off from the image.

    read_package_manifest

    async fn read_package_manifest(pkg : String) -> Manifest

    Read the manifest of a package file.

    recover_encryption

    async fn recover_encryption(info : Encryption, secret_key : String, passphrase : String) -> PackageKey

    Recover the key of a package that was sealed.

    The path of the file that holds the secret key is only used by the x25519 method; the passphrase method needs the passphrase.

    redundant_md5

    fn redundant_md5(data : Bytes) -> String raise

    The MD5 of data, computed twice.

    The MD5 is not a security checksum, but it is what the platform tooling shows, and it is an algorithm that is independent of SHA-256. Two independent algorithms that agree are stronger evidence than one of them twice.

    redundant_sha256

    fn redundant_sha256(data : Bytes) -> String raise

    The SHA-256 of data, computed twice.

    relpath

    fn relpath(base : String, full : String) -> String

    Path of full relative to base.

    remove_dir

    async fn remove_dir(path : String) -> Unit

    Remove the folder at path and everything below it, whether it is there or not.

    @fs.rmdir takes allow_missing, but the flag only covers the removal of the folder itself: a recursive removal opens the folder first, and that fails when there is none. So the folder is looked at before it is removed.

    report_md5

    fn report_md5(path : String, digest : String) -> Unit

    Print a fingerprint that was computed while the file was written.

    The file does not have to be read back for that, which matters when it is a large application.

    report_output_md5

    async fn report_output_md5(path : String) -> Unit

    Print the MD5 fingerprint of the file at path.

    The fingerprint of the produced file is what a user compares against the value the platform tooling shows, so it is printed under a clear label.

    run_install_script

    async fn run_install_script(platform : String, kind : String, script : Script, dest : String) -> Int

    Run an install script of kind inside dest and return its exit status.

    platform selects the interpreter, which makes the POSIX path testable on any host.

    save_public_key

    async fn save_public_key(path : String, public_key : Bytes) -> Unit

    Write the public key of a pair to path.

    This is the older form: the 32 raw bytes of the encryption public key alone. A file written this way cannot be used to check a signature; use save_public_key_pair for a pair that serves both purposes.

    save_public_key_pair

    async fn save_public_key_pair(path : String, encryption : Bytes, signing : Bytes) -> Unit

    Write the two public keys of a pair to path.

    The file is a text, so that a person can read what it holds.

    save_secret_key

    async fn save_secret_key(path : String, secret_key : Bytes, passphrase : String) -> Unit

    Write the secret key of a pair to path, protected by a passphrase.

    save_secret_key_raw

    async fn save_secret_key_raw(path : String, secret_key : Bytes) -> Unit

    Write the secret key of a pair to path, without a passphrase.

    The key is in clear text on disk, so anyone who reads the file can decrypt every package that was sealed for it. Use the protected form unless there is a reason not to.

    script_applies

    fn script_applies(script_platform : String, host : String) -> Bool

    Does a script targeting script_platform apply on host?

    "any" always applies, "posix" covers Linux and macOS.

    script_file_name

    fn script_file_name(platform : String, kind : String) -> String

    The name of the file the script of kind is written to.

    The name is the same on every platform, and it is a name and not a path: the script is written into the directory the interpreter runs in.

    script_platform_of

    fn script_platform_of(path : String) -> String

    Infer the target platform of a script from its file extension.

    seal_entry

    fn seal_entry(key : PackageKey, index : Int, path : String, payload : Bytes) -> Bytes

    Seal the payload of the entry with the given index.

    The result is the ciphertext followed by the 16-byte tag.

    seal_to_public_key

    fn seal_to_public_key(recipient : Bytes) -> SealedPackage

    Establish the shared secret of a package that is sealed to the owner of the public key recipient.

    An ephemeral key is made for every package, so that the same recipient gets a different shared secret for every package.

    set_pe_subsystem

    async fn set_pe_subsystem(path : String, subsystem : Int) -> Bool

    Set the subsystem of the PE image at path.

    The value decides whether Windows opens a console window for the process. A bundle has to carry the subsystem of the program it carries, because the bundle is what Windows starts: a windowed program that is bundled into a console bundle flashes a console window on every start.

    The change is written in place, at the offset of the field, and the file keeps its size.

    set_permissions

    async fn set_permissions(platform : String, path : String, mode : Int) -> Unit

    Set UNIX permission bits on path.

    Windows has no UNIX permission bits and the async runtime's chmod is not implemented there, so the call is skipped on Windows.

    sha256_hex

    fn sha256_hex(data : Bytes) -> String

    Lowercase hex SHA-256 digest of data (64 characters).

    sha256_hex_of_string

    fn sha256_hex_of_string(s : String) -> String

    Lowercase hex SHA-256 of the UTF-8 encoding of s.

    sha512

    fn sha512(data : Bytes) -> Bytes

    The SHA-512 digest of data: 64 raw bytes.

    sha512_hex

    fn sha512_hex(data : Bytes) -> String

    The SHA-512 digest of data, lowercase hex (128 characters).

    shared_with_secret_key

    fn shared_with_secret_key(secret : Bytes, ephemeral : Bytes) -> Bytes

    The shared secret of a package, seen from the side of the recipient.

    shell_for

    fn shell_for(platform : String) -> (String, Array[String], String)

    How a script must be executed on platform: (interpreter, extra arguments, script file extension).

    Windows batch scripts need cmd /c; POSIX shells need plain sh.

    sign_package

    async fn sign_package(path : String, secret : Bytes) -> PackageSignature

    Sign the package at path with the secret key secret.

    The secret key is the one of the pair: the seed of the signing key is derived from it, so the key that signs is not the key that decrypts.

    The result says which package it belongs to: the digest of the package and the digest of its manifest.

    signature_from_text

    fn signature_from_text(text : String) -> PackageSignature?

    The signature in the text of a signature file.

    Returns None when the text is not a signature file or when a field of it is not what it has to be.

    signature_to_text

    fn signature_to_text(sig : PackageSignature) -> String

    The text of a signature file.

    The file is a text, so that a person can read what it says and a script can check it without a library.

    string_to_utf8_bytes

    fn string_to_utf8_bytes(s : String) -> Bytes

    Encode a String into UTF-8 bytes. NOTE: MoonBit strings are UTF-16 internally, so String::to_bytes yields UTF-16 and must not be used for on-disk data. The bundled @utf8 codec gives real UTF-8.

    text_lines

    fn text_lines(text : String) -> Array[String]

    The lines of a text, without the line endings.

    The lines are trimmed, so a file that was written on another machine, with other line endings, is read the same way.

    truncate_bytes

    fn truncate_bytes(data : Bytes, count : Int) -> Bytes

    The first count bytes of data.

    try_launch_self_extracting

    async fn try_launch_self_extracting() -> Bool

    Extract the payload carried by the running executable and start the bundled program.

    The arguments of the bundle are the arguments of the bundled program, and the exit code of the bundle is the exit code of the bundled program. A bundle that is started therefore behaves like the program it carries.

    Returns false when the executable carries no archive, in which case the caller should behave like an ordinary command line tool.

    unpack

    async fn unpack(pkg : String, dest : String, run_scripts : Bool, key : PackageKey?) -> Unit

    Extract a package into dest.

    run_scripts controls whether the embedded pre_install / post_install scripts are executed.

    utf8_bytes_to_string

    fn utf8_bytes_to_string(bs : Bytes) -> String

    Decode UTF-8 bytes into a String. NOTE: Bytes::to_string() returns the debug representation b'\x..', not the decoded text, so it must not be used here.

    verify

    async fn verify(pkg : String, key : PackageKey?) -> Bool

    Check every entry of a package: magic, sizes, SHA-256 and MD5 digests.

    verify_extracted_dir

    async fn verify_extracted_dir(pkg : String, dir : String) -> Bool

    Check the files in dir against the manifest of pkg.

    This audits what is already on the disk, which is what the user asked for: the files that were extracted must still carry the fingerprint they had in the package. It works on a folder that was filled by unpack and on the cache folder of a self-extracting bundle.

    Returns false when a file is missing or its fingerprint changed.

    verify_package_digest

    async fn verify_package_digest(path : String, expected : String) -> Bool

    Does the package at path have the digest expected?

    The digest is the anchor that a host publishes next to a package. It needs no key at all, so it works for a package that anybody may hold.

    verify_package_signature

    async fn verify_package_signature(path : String, sig : PackageSignature, public : Bytes) -> Bool

    Does the signature hold for the package at path?

    public is the signing public key of the host: the Ed25519 one, not the one a package is sealed to.

    The digest of the package is computed again and compared with the digest the signature was made over, and the signature itself is checked with the public key. A package that was changed after it was signed does not match the digest, and a signature that was made over another package does not hold.

    with_defaults

    fn with_defaults(opts : PackOptions, name : String, version : String, os : String, arch : String) -> PackOptions

    Return a copy of opts with the given metadata fields replaced.

    with_encryption

    fn with_encryption(opts : PackOptions, encryption : EncryptionOptions) -> PackOptions

    Return a copy of opts that encrypts the package.

    with_main_program

    fn with_main_program(opts : PackOptions, name : String) -> PackOptions

    Return a copy of opts whose main program is name.

    x25519

    fn x25519(scalar : Bytes, point : Bytes) -> Bytes

    The X25519 function of RFC 7748: the shared secret of the private key scalar and the public key point.

    Returns 32 bytes. A shared secret of all zeros means that the peer sent a point of a small order, which the caller has to refuse.

    x25519_public_key

    fn x25519_public_key(scalar : Bytes) -> Bytes

    The public key of the private key scalar: the point with the base point of the curve.

    xxhash32

    fn xxhash32(data : Bytes, start : Int, end : Int, seed : UInt) -> UInt

    XXH32 of data[start..end).

    The LZ4 frame format uses this for the checksum of its header.

    zero_bytes

    fn zero_bytes(count : Int) -> Bytes

    The bytes of count zeros.