LunaPack

    Application distribution packer built with MoonBit

    packaging
    distribution
    cli
    Download zip
    Author
    Version
    0.1.23
    License
    MIT
    Last updated
    2 hours ago
    Downloads
    113

    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.

    • The libraries of the program are found and carried

      A Windows program is the executable and the libraries it loads, and the names of those libraries are written in the import table of the executable. LunaPack reads that table — and the tables of the libraries in it — and carries the libraries that sit next to the program, so a bundle of a program with its DLLs needs nothing but the program and the output path. See The dependencies of the program.

    • 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.23

    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.23", }

    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.23, >=0.1.23, 0.1 and v0.1.23 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.20261104 — 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.

    That moving target is not theoretical. moonbitlang/core/async/process changed its output API between those two generations: redirect_to_file() used to return a &ProcessOutput trait object and now returns a SharedProcessOutput, while spawn_orphan() now expects a ProcessOutput enum variant instead. The first 0.1.18 tag failed on every runner for exactly this reason. The Studio launcher was rewritten to use only the arguments the two signatures share, so the published source compiles on either toolchain.

    #What changed in 0.1.23

    • The libraries of the program are found and carried. A Windows program is not one file: it is the executable and the libraries it loads, and a bundle that carried only the executable started and then stopped with STATUS_DLL_NOT_FOUND (0xC0000135). The names of those libraries are written in the import table of the executable, and LunaPack reads that table now — and the delay-load directory with it, and the tables of the libraries in turn. The libraries that sit next to the program are carried next to it in the bundle, which is where the program looks for them.

      A bundle is therefore made with the program and the output path alone:

      lunapack --input-exe=./build/myapp.exe --output-exe=./dist/myapp.exe

      The libraries of the system and the API sets (api-ms-win-…, whose files do not exist at all) are left out, and so is every library that is not next to the program: it is either part of the system or one the program finds somewhere else, and neither is something a bundle can carry. The list of what was found is printed, so that a bundle whose size nobody can explain does not happen, and --no-auto-deps says that only the files named with --Files are carried.

      What is not found, because nothing in the binary says it: a library the program opens with LoadLibrary when it runs, and every data file it reads. Those are still named with --Files.

      The section "The dependencies of the program" has the whole of it.

    • The suite grew from 158 to 179 tests, and the battery of mutations grew from 21 to 33: every guard of the new discovery was taken out of the code once, and the test that covers it went red. The table under "Every guard was taken out once" lists them.

    • Nothing else changed: the tool behaves exactly as it behaved in 0.1.22.

    #What changed in 0.1.22

    • The README says how to bundle from another module. The tool was always meant to be run by a build, and moonx riantr/LunaPack@0.1.22 ... runs it on a machine that has no MoonBit toolchain. What was missing was the recipe that wires it into the build of another module: MoonBit has no hook that runs on an import — a dependency is not allowed to run commands in the build of whoever depends on it, and that is on purpose — but it has a rule in moon.mod and a dev_build in moon.pkg, and a rule can run moonx. The section "Bundling from another module" has the configuration and the two things that have to be kept in mind: a dev_build runs before the build, so the rule has to build the application first, and the version of LunaPack has to be named, or every build can pick a different packer.

    • Nothing else changed: the tool behaves exactly as it behaved in 0.1.21.

    #What changed in 0.1.21

    • A warm start shows a bar. A bundle that is started a second time does not unpack the payload again: it reads the payload back and compares it with the package, which is what proves the cache still holds what it belongs to. That read-back now tells the caller how far it has come, so a bundle that owns a console draws a verify bar while it reads the payload back. Before this, a warm start drew nothing at all while it read a hundred megabytes from the disk, and a start that says nothing looks like a start that hangs.

      Measured on a bundle of 151 files (150 MB) that owns a console: the start after the first drew 151 verify frames where it had drawn none.

    • The payload of a bundle that cannot be unpacked next to it is cached anyway. The folder next to the executable cannot always be written to, and the payload used to go to a folder made by @fs.tmpdir — which makes a folder of its own on every call and never uses one twice. The cache was therefore never reused: every start unpacked the whole payload again, into a folder that was thrown away at the end of it. The payload goes to the cache of the user instead — %LOCALAPPDATA%/LunaPack/cache/<hash>/ on Windows, $XDG_CACHE_HOME/lunapack/ or $HOME/.cache/lunapack/ elsewhere, and LUNAPACK_CACHE_HOME names the place itself — which is the same on every start. The temporary folder of the system is only used when none of them can be written to, and then for that one start only.

      Measured on the same bundle, with the folder next to it made unusable: the start after the first went from 16.4 s to 4.1 s, and it stopped leaving a copy of the payload behind in the temporary folder.

    • A path that cannot be looked at is answered with "no". @fs.exists raises when the path cannot be looked at — a file in the way of a folder is such a path — and the launcher asked it about the places it was about to write to. The answer ended the start. The launcher now goes on to the next place instead.

    • The generated interface of the package is up to date. The 0.1.20 release shipped the three functions of the launcher auto-download without regenerating pkg.generated.mbti, so the committed interface still said 0.1.19. The file is now what moon info produces.

    #What changed in 0.1.20

    • --launcher auto-download. A self-extracting bundle is a native LunaPack executable with the archive appended. When LunaPack runs as Wasm (for example under moonx) it has no native executable of its own to use as the bundle stub, so bundle could only produce a non-self-extracting file. --launcher with no file now downloads the native launcher for the current system from this release (lunapack-<os>-<arch>[.exe]) and shows a progress bar plus a 正在下载 launcher status line while it works. An explicit path still works as before; LUNAPACK_LAUNCHER_URL overrides the source for mirrors or air-gapped installs, and the download is cached per version.

    • --studio alias. LunaPack --studio (and --Studio, --studio=true, --Studio=true) now launch LunaPack Studio, mirroring the existing --GUI / --gui spelling variants.

    • Executable bit on POSIX. The produced self-extracting bundle is now made executable on Linux/macOS so it can run on its own.

    #What changed in 0.1.19

    • LunaFlow-driven hardening. The collect_files traversal in pack.mbt used while true and was flagged by the static analyzer as a possible infinite loop. More importantly, on a real filesystem a symlink cycle would actually loop forever. The loop now terminates on an explicit stack-empty check and remembers visited directories, so every directory is expanded at most once — termination is provable and symlink cycles are broken.

    • Crypto bounds guards. md5_compress and sha512_compress now check the block length up front (off + 64 / off + 128); a contract violation can no longer become an out-of-bounds read. The callers still always pass block-aligned offsets.

    • Documented analyzer false positives. The TAINT-INDEX entries on dirname/basename (platform.mbt), hex_to_bytes (hex.mbt), parse_flags (cli.mbt) and decimal (studio.mbt) were reviewed: each index is bounded by an explicit length guard, so they are not real bugs. Comments now say so.

    • Tests and checks. moon check --target native is clean and the full suite (150 tests) passes.

    #What changed in 0.1.18

    • LunaPack and LunaPack Studio are one product. There is one version, one tag and one GitHub release. Studio is an optional asset of that release, not a second package on Mooncakes and not a separately numbered program. The 0.1.18 in moon.mod, in CLI_VERSION and in the Studio window are the same number; a whitebox test reads moon.mod and refuses to let them drift.

    • --GUI opens the graphical front end. lunapack --GUI finds a Studio build next to the CLI (or in the versioned cache), downloads lunapack-studio-<os>-<arch>.zip from this version's release when it is missing, and shows a live progress bar while it streams the archive. The download is extracted in pure MoonBit - no unzip, tar or PowerShell is needed - and entry names are checked so a crafted archive cannot write outside the install directory. HTTPS_PROXY / NO_PROXY are honoured, and loopback mirrors always bypass the proxy.

      lunapack --GUI lunapack --GUI --input-file=./myapp.exe --output-file=./myapp-bundle.exe \ --Files=./assets --Files=./docs --compression-method=lz4

      The flags are forwarded, not acted on: the window opens with those fields already filled in. --cli=<path> is added automatically so the window drives the very LunaPack that started it, even when Studio was downloaded into a cache folder far from the checkout. Override the location with LUNAPACK_STUDIO, the cache with LUNAPACK_STUDIO_HOME, or the source with LUNAPACK_STUDIO_URL (useful for a mirror or an air-gapped install).

    • The flag documentation now matches the parser. The no-subcommand (moonx riantr/LunaPack@latest ...) front end reads --input-exe / --input-file and --output-exe / --output-file. The bundle subcommand reads -i / --input and -o / --output. The long -exe / -file spellings pass the unknown-flag check but are ignored by bundle, which then reports "bundle requires -i <input_exe> and -o <output_exe>". This was found by driving a real bundle with those flags; the examples below now say which form each flag belongs to.

    • Startup arguments are part of the contract. Studio parses every alias in both --flag value and --flag=value form, including repeatable --Files, and folds them into its form. Empty forwarded fields keep the defaults; an explicit --cli is never overwritten by auto-discovery.

    • Two defects found by running the real 188 MB asset. Windows packers write \ as the separator inside a zip; taken verbatim those names became mixed-separator paths and @fs.mkdir reported "The system cannot find the path specified". Entry names are normalised to / before any path is built, and a trailing separator no longer turns a directory entry into a file. A real 246-file / 419 MB extraction now completes. The other defect was a window that inherits the CLI's stdout and keeps a pipe open, so lunapack --GUI | ... never saw EOF. Redirecting the child's streams into studio.log fixed it on the development toolchain, but the @process output API then changed shape (see above) and no stdout=/stderr= argument can be written that compiles on every toolchain. Since this module ships as source, portability wins: the streams are inherited and a piped invocation reaches EOF when the window is closed.

    • The suite grew from 135 to 150 tests on both the native and the Wasm backend. The new cases cover GUI-flag detection, argument forwarding, download URL construction, proxy bypass, redirect resolution, Windows zip separators, zip-slip rejection and the version/asset-name drift detector.

    #What changed in 0.1.17

    • --files and --Files are the same flag. This documentation and every example write --Files, and the parser matched a flag name exactly, so a command line that reached for --files was refused with unknown flag--files. It is read as the same flag now, in the moonx form and in the subcommand form alike, and the two spellings can be mixed in one command line: the folders come back in the order they were given, because the values are collected in one pass over the arguments rather than as two lists that are joined afterwards.
    • Nothing else became case-insensitive. --file is still a mistake and is still reported, as is every other near miss. An exact list is what turns a misspelled flag into an error instead of a command that quietly does something else, and that property is kept: the one flag with two spellings is listed twice, deliberately, and the test that lists every flag the tool reads now checks both.
    • --os is accepted, which it never was. build_options read --os and this page documented it, but the name was missing from the list of known flags, so every command line that set the platform was refused with unknown flag --os: a package for another platform could not be made at all. It was found by putting the flags the tool reads next to the flags the tool knows and looking for the difference, and it was the only flag in that gap.
    • A test that failed about once in 256 runs does not any more. The test that changes a signed package wrote Z over byte 30 to break the signature. Byte 30 is inside the random salt of the manifest block, so one time in 256 it was already a Z, nothing changed, and the signature still held — which is what happened to one wasm run of the gate sweep before this release, while two runs straight after it passed. The byte is turned into a different one now instead of into a fixed one.

    Two of these changes have their guards in the battery. One test holds the two spellings of the folder flag, a command line that mixes them, and the space-separated form of each; the mutation that drops the lowercase spelling from the collector — leaving it a known flag that contributes no folder, which is the quiet kind of wrong — turns that test red and only that test. The platform flag is held by the test that names it, and the mutation that takes os back out of the list of known flags turns it red, together with the test that lists every flag the tool reads — two witnesses, because a flag that is read and refused is visible from both sides.

    The --launcher default is unchanged, and it is worth writing down because it is the question this release was asked about. On a native build the launcher defaults to the executable that is running, so --launcher may be left out entirely: measured here, bundle with --launcher <the tool itself> and bundle without it produce byte-identical output. Under moonx there is no native executable to default to, and omitting the flag produces a file that carries the payload but cannot extract itself — the warning and the hint at the end of bundle say exactly that, and the file is still usable with lunapack unpack.

    #What changed in 0.1.16

    • The fifteen seconds of the menu now pass. 0.1.15 counted down by waiting one second on the console input handle and reading when the wait said there was something. That wait is on the handle, and a console signals the handle for anything at all: a window that gained focus, a window that was resized, the mouse. Measured on this machine: a wait on the input handle of a console with no key pressed returns WAIT_OBJECT_0 in 0–95 ms, every time. The read that followed is ReadConsoleW, which in the line-input mode of a console waits for a whole line — for ever, when nobody is typing. The countdown therefore never counted a second: a double-clicked bundle with a cache that is not complete sat on the menu showing 2 in 15 s and never started.
    • The wait is no longer taken for an answer. What is on the buffer is looked at before anything is read: only a KEY_EVENT that went down and carries a character is an answer. Everything else — the focus change, the resize, the mouse, the key that was let go, the arrow key with no character — is taken off the buffer with ReadConsoleInputW instead of being read as text, because leaving it there would signal the very next wait and no second would pass either.
    • The rule is a plain function, so it can be tested without a console. has_key_press(records, count) walks the 20-byte INPUT_RECORDs and says whether one of them can answer a question. The test holds four buffers — a focus change, a key going up, an arrow key, and the 2 pressed — and each of the four conditions has to hold for the last one to be the answer. The mutation that takes the condition out — "anything the console reports is an answer", which is exactly what 0.1.15 shipped — turns that test red and only that test.

    The defect was found by starting a bundle on a console that belonged to it and reading the screen: after several minutes it still said 2 in 15 s, and typing 2 and Enter let it through at once — so the menu itself was usable and only the "nobody chose" path was dead. With this release the same run counts 15, 14, 13 … 1, prints 2 (nobody chose), unpacks behind the two bars and starts the program.

    One limit is worth stating: a key that carries no character is not an answer. An arrow key or a function key therefore does not move the menu along, and the countdown keeps going. That is deliberate — the question is [1/2] — but it means the menu is answered by typing a digit and not by pressing whatever key is nearest.

    #What changed in 0.1.15

    • A first start asks what it should do. A bundle that is double-clicked, carries no argument of its own, and has never unpacked its payload prints three lines before it does anything:

      LunaPack: <name> <version> [1] install it to a folder [2] run it from the folder next to this file your choice [1/2], 2 in 15 s:

      Fifteen seconds without an answer take 2, and the line then says so — your choice [1/2]: 2 (nobody chose) — rather than leaving the wait unexplained. A bundle that nobody is watching still starts on its own.
    • 1 is the unpack path and not a second implementation of it. It asks for a folder, offering %LOCALAPPDATA%\Programs\<name> when Enter is pressed, and then runs the code unpack runs — the pre and post scripts of the package included — and starts the program from that folder with the folder as its working directory. What a bundle installs is therefore what lunapack unpack produces, and the two cannot drift apart.
    • The menu is only for a person. It needs all four of: no argument, a console the bundle created for itself, stdin that is a real console (asked of GetConsoleMode), and a payload that is not already cached in full. A script, a pipe, a start from a terminal and every start after the first one go straight to the program, so nothing waits fifteen seconds for an answer that cannot come.
    • The unpacking shows a bar. The line is rewritten in place: a bar of 24 cells, the percentage, the files done out of the total, the megabytes done out of the total, and the name of the entry being written. The whole line, its unpack/verify label included, is 78 characters — cut or padded to exactly that, so the tail of the line before it cannot be left on the screen. On the path that unpacks next to the bundle there are two bars, unpack and then verify, because the payload is read back after it arrives; the install path shows the first one.
    • The bar cannot claim more than the whole. A count of bytes larger than the total the manifest gives is clamped at 100%, so a package whose last entry is bigger than the sum of the manifest reads as finished rather than as a defect in the unpacking. The mutation that removes the clamp turns the test of the bar red.
    • A name the console cannot show is left out. The entries of a package are whatever the packer put in it, so the name at the end of the line is printed only when it is plain ASCII; guessing at a code page would turn the bar into noise.
    • The bar is written to the console handle, not through a buffer. The launcher calls WriteFile on the standard output handle it got from GetStdHandle, which is what lets it write while a package is being unpacked and after a console was handed on. Nothing of this is visible to the interface of the package: progress.mbt is private, extract_to, unpack and cache_matches keep the signatures they had, and a bar is an optional argument the launcher passes.

    Three limits are worth stating plainly. The install path shows the bar of the unpacking only: the digest re-read that unpack does at the end is silent, so the last seconds of an install look idle. A first start from a terminal shows no bar either, because that console is the terminal's and the line would be mixed into the output of the program the bundle is about to start; the bar belongs to the console the bundle made for itself. And on a platform whose console belongs to the terminal there is no menu at all — ask_install_or_run answers 2 there, because "install to a folder" is what unpack already does and there is no console to ask on.

    #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
    lunapack-studio-windows-x86_64.zipLunaPack Studio, the graphical front end of this same release

    LunaPack and LunaPack Studio are not two packages. Studio has no version of its own and is not published to Mooncakes: it is an optional asset attached to the same release as the CLI, and the CLI knows which asset to ask for because both carry the version in moon.mod. lunapack --GUI downloads it on first use, so a user who only wants the command line never pays for the 188 MB window and a user who wants the window never has to find a second download. Assets for other platforms are named lunapack-studio-<os>-<arch>.zip and are added as those builds exist.

    The release is built by a GitHub workflow when a version tag such as v0.1.23 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.

    When that folder cannot be written to — a read-only medium, a folder the user may not touch — the payload goes to the cache of the user instead: %LOCALAPPDATA%/LunaPack/cache/<hash>/ on Windows, $XDG_CACHE_HOME/lunapack/ or $HOME/.cache/lunapack/ elsewhere. LUNAPACK_CACHE_HOME names that place itself and takes precedence over all of them. That folder is the same on every start, so the payload is unpacked once there too; the temporary folder of the system is only used when none of them can be written to.

    The first start unpacks the payload and then reads it back, and both passes draw a bar (unpack, then verify) when the bundle has a console of its own. A start that finds the cache still reads the whole payload back — that is what proves the cache still holds what the package carries — and it draws the verify bar while it does so.

    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 dependencies of the program

    A Windows program is not one file: it is the executable and the libraries it loads. A bundle that carries only the executable starts and then stops with STATUS_DLL_NOT_FOUND (0xC0000135), and the list of what is missing is written in the import table of the executable.

    LunaPack reads that table, and the tables of the libraries in it, and carries the libraries that sit next to the program:

    # Only the program and the output: the libraries next to the program are # found and carried as well. lunapack --input-exe=./build/myapp.exe --output-exe=./dist/myapp.exe

    The libraries that are carried land at the root of the archive, next to the program, which is where the program looks for them. The list is printed, so that a bundle whose size nobody can explain does not happen:

    discovered 2 dependencies next to the program: mylib.dll otherlib.dll

    What is carried:

    • the libraries the program imports, and the libraries those libraries import;
    • only the ones that are next to the program: a library that is not there is either part of the system or one the program finds somewhere else, and neither is something a bundle can carry;
    • not the libraries of the system: the system loads those from its own copy whatever the program carries next to itself;
    • not the API sets (api-ms-win-…), whose names the system resolves and whose files do not exist at all.

    --no-auto-deps turns the whole thing off, and then only the files named with --Files are carried.

    What is not found, because nothing in the binary says it:

    • a library the program opens with LoadLibrary when it runs;
    • every data file it reads — a configuration, a model, an image.

    Those are still named with --Files, as they always were. What the discovery saves is the list of DLLs, because that is the part of the dependencies that is written down in the binary, and the part that is forgotten most often.

    The import table is a Windows thing. A program for Linux names its libraries in the dynamic section of an ELF image and a program for macOS in the load commands of a Mach-O image, and neither is read yet.

    #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.

    #Bundling from another module

    The tool is meant to be run by a build, not by a person. The shortest way to run it is moonx, which fetches the published module and runs its WebAssembly build — a machine with no MoonBit toolchain can still pack:

    # The package of the application, as one file. This is the artifact to publish. moonx riantr/LunaPack@0.1.23 pack -i ./app -o app.appkg # The self-extracting single file. moonx riantr/LunaPack@0.1.23 bundle -i ./app.exe -o app.exe --Files=./assets --launcher

    --launcher with no file downloads the native launcher of the current system and appends it, and that is what makes the result start on its own: the WebAssembly build has no native stub of its own to append.

    MoonBit has no hook that runs on an import, so a dependency cannot pack anything for the module that depends on it. That is deliberate — the pre-build steps of a package "are not triggered for security reasons" when the package is used as a dependency, so dependencies never run commands in the build of whoever depends on them. What MoonBit does have is a rule in moon.mod and a dev_build in moon.pkg, and a rule can run moonx:

    # moon.mod rule( name: "lunapack", command: "moonx riantr/LunaPack@0.1.23 bundle -i \"$input\" -o \"$output\" --Files=./assets --launcher", ) # moon.pkg dev_build(rule: "lunapack", input: "build/app.exe", output: "dist/app.exe")

    $input and $output are the two paths of the dev_build entry, and the command runs with the module root as its working directory, so the paths in it are relative to that root.

    Two things have to be kept in mind. The first is that a dev_build is a pre-build step: it runs before moon check, moon build and moon test, so the executable it is handed does not exist yet — a rule that only packs what is already there packs nothing, and the rule has to build the application first. The second is that a rule has to name the version of LunaPack it uses: @latest refreshes the registry index on every build, and a build that can pick a different version of its own packer is not a build that can be reproduced.

    #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; --files is the same flag)

    --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 graphical front end (--GUI)

    lunapack --GUI lunapack --GUI --input-file=./myapp.exe --output-file=./myapp-bundle.exe \ --Files=./assets --compression-method=lz4 --name=demo --version=1.0.0

    --GUI is a front end of its own, not a flag on a subcommand. It opens LunaPack Studio with the flags above already filled in, so the window is a review-and-press-Process step rather than a form to start from scratch. The switch may appear anywhere on the command line. Everything the user typed is forwarded unchanged, plus --cli=<this executable> so the window drives the same LunaPack.

    Studio is looked for in this order:

    WhereControlled by
    an explicit executable or directoryLUNAPACK_STUDIO
    beside this CLI, or in a sibling LunaPackGUI/dist/ checkout-
    %LOCALAPPDATA%/LunaPack/Studio/<version>/ (or $XDG_CACHE_HOME, $HOME/.cache)LUNAPACK_STUDIO_HOME

    If none exists, the CLI downloads lunapack-studio-<os>-<arch>.zip from https://github.com/riantr/LunaPack/releases/download/v<version>/, prints a progress bar on stdout while it streams, extracts it into the versioned cache and starts it. The cache is versioned so a newer CLI never silently opens an older window. A mirror or an offline install can set LUNAPACK_STUDIO_URL to any URL (or a file served over loopback in a test); a bad archive is refused with a message naming the reason.

    HTTPS_PROXY, HTTP_PROXY and their lowercase forms are honoured through a CONNECT tunnel. NO_PROXY (including its * wildcard) and all loopback hosts are always contacted directly, so a local mirror is not sent out through a corporate proxy. The window inherits the CLI's stdout and stderr: the @process APIs that would redirect them are not stable across toolchains, and a GUI window writes nothing to the console in normal use. A piped lunapack --GUI | ... therefore reaches EOF when the window is closed.

    The Wasm / moonx build cannot open a window. On that target --GUI prints the asset URL it would have downloaded and exits with an error instead of pretending to succeed.

    #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.

    Which flag names does bundle actually read? Only -i / --input and -o / --output. The --input-exe, --input-file, --output-exe and --output-file spellings belong to the no-subcommand front end used by moonx riantr/LunaPack@latest ... (and to --GUI, which forwards them to the window). They are in the known-flag list so they are not reported as typos, but do_bundle does not consume them: passing them to bundle produces "bundle requires -i <input_exe> and -o <output_exe>". The examples in this file use the form that matches the command they show.

    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 in the no-subcommand / --GUI form only
    --output-file <path>another name for --output-exe in the no-subcommand / --GUI form only
    --input <path> / -ithe input of the bundle, pack and unpack subcommands
    --output <path> / -othe output of those subcommands
    --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)
    --no-auto-depsdo not carry the libraries the program needs next to it
    --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.20261104): moon test --targetnative reports 179 tests, 179 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 clamp of the barthe progress bar tells the truth about what is left
    the answer of the consoleonly a key press answers a question
    the second spelling of the folder flagthe folder flag is read under both spellings
    the platform flagthe platform flag is a flag the tool knows
    the report of the check of a cache that is used againthe files of a cache that is used again are counted one by one
    the folder of the cache of a bundle that cannot be written next to itthe cache of a bundle that cannot be written next to it is found again
    the import table of the programthe import table of a PE image names the libraries it imports
    the delay-load directory of the programthe libraries that are loaded later are named as well
    the rule of the API setsthe name of an API set is not a library of the program
    the rule of the libraries of the systemthe name of a library of the system is not a library of the program
    the rule "only the libraries next to the program"a library that is not next to the program is not carried
    the libraries of a librarythe libraries of a library are found as well
    the case of the name of a filea library is found whatever the case of the file is
    the libraries in the bundlethe libraries next to the program are carried in the bundle
    the place of the libraries in the bundlethe libraries next to the program are carried in the bundle
    the flag --no-auto-depsthe flag --no-auto-deps leaves the libraries out
    the flag --no-auto-deps among the known flagsthe flag --no-auto-deps is a flag the tool knows
    the rule "a file is carried once"a library that the caller named is not carried twice

    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 guards the signing key of a pair and the separation of the two roles are there because of a defect the Linux end-to-end suite found in 0.1.8 and 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, the cache of the user is used instead (%LOCALAPPDATA%/LunaPack/cache/<hash>/, or $XDG_CACHE_HOME, $HOME/.cache, or LUNAPACK_CACHE_HOME). That place is the same on every start, so the payload is unpacked once there as well; only when none of them can be written to does the temporary folder of the system take the payload, and that folder is then used for that one start only.
    • 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 review the same job in a window. Studio is part of this release and is downloaded on first use:

    lunapack --GUI --input-file=./build/myapp --output-file=./dist/myapp --Files=./build

    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 # 179 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.

    ZipEntry

    type ZipEntry

    One file in the central directory.

    CHECKSUM_ALGO

    let CHECKSUM_ALGO : String

    Checksum algorithm used for every entry payload.

    CLI_VERSION

    let CLI_VERSION : String

    The version of this CLI. studio_wbtest.mbt reads moon.mod and refuses to let the two drift apart, because the download URL is built from this.

    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.

    bundle_with_files

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

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

    The files in extra_files land at the root of the archive, next to the main program. That is where the libraries of a Windows program have to be: the program looks for them next to itself, and the bundle unpacks itself into one folder.

    A file that is already in the archive is not put in twice: the file the caller named comes first, and it is the one that is kept.

    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.

    Two things make this version provably terminating and safe:

    1. The loop stops on an explicit, observable condition (the work stack empties) instead of while true, which the static analyzer could only model as a possible infinite loop.
    2. Every directory is recorded the first time it is expanded, and a directory is only queued again when it has not been seen. That breaks symlink (or any other) cycles, which would otherwise make the walk loop forever on real filesystems.

    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.

    discover_dependencies

    async fn discover_dependencies(exe : String) -> Array[String]

    The DLLs that a program needs from the folder it sits in.

    The names come from the import table of the program and from the import tables of the libraries it needs in turn. Only the ones that are actually next to the program are returned: a library that is not there is either part of the system or one the program finds somewhere else, and neither is something a bundle can carry.

    The paths that come back are absolute, and they are the files themselves: the ones next to the program, in the order they were found.

    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.

    extract_zip

    async fn extract_zip(data : Bytes, dest : String) -> Unit

    Unpack every entry of data below dest, creating parent directories.

    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_api_set_dll

    fn is_api_set_dll(name : String) -> Bool

    Is name the name of an API set?

    An API set is a name the system gives to a group of functions, and the library that carries it is a virtual one: there is no such file on the disk, and the system resolves the name to a real library when the program is started. A bundle that carried one would carry nothing.

    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_known_dll

    fn is_known_dll(name : String) -> Bool

    Is name a library the system always loads from its own copy?

    The system keeps a list of these, and it loads them from itself whatever the program carries: the file next to the program is never used, so carrying it would only make the bundle bigger.

    is_loopback_url

    fn is_loopback_url(url : String) -> Bool

    A loopback server is a local mirror or a test fixture. Sending those requests out through a corporate proxy either fails or loops back into the wrong machine, so they always go direct.

    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.

    launch_studio

    async fn launch_studio(argv : ArrayView[String]) -> Unit

    Start Studio with the command line that was given to the CLI.

    launcher_asset_name

    fn launcher_asset_name() -> String

    The name of the native launcher asset for the current system.

    One build per platform is shipped (see .github/workflows/release.yml): lunapack-linux-x86_64 lunapack-windows-x86_64.exe lunapack-macos-aarch64 A bundle for the current machine needs a launcher built for it, and these are the builds that exist, so the name resolves to one of them rather than to a guessed architecture that would only 404.

    launcher_dir

    fn launcher_dir() -> String

    Where a downloaded launcher lives. The directory is versioned, so a newer CLI does not silently start an older launcher.

    launcher_release_url

    fn launcher_release_url() -> String

    Where the launcher comes from. A mirror or an air-gapped install can override the whole URL; the default is the release that carries this CLI.

    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.

    normalized_zip_name

    fn normalized_zip_name(name : String) -> String

    Windows packers often write \ as the separator inside a zip. The zip format specifies /, but a name taken verbatim would produce a path with mixed separators: dest/foo\bar\baz. On Windows @fs.mkdir then reports "The system cannot find the path specified", and a directory entry ending in \ is mistaken for a file. Normalise once, before any path is built.

    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_delay_import_dlls

    fn pe_delay_import_dlls(data : Bytes) -> Array[String]

    The names of the DLLs that a PE image loads with delay loading.

    A program that loads a library later, when it needs it, writes the name into the delay-load directory instead of the import directory. Such a program cannot start without the library either, so its names are read as well.

    pe_import_dlls

    fn pe_import_dlls(data : Bytes) -> Array[String]

    The names of the DLLs that a PE image imports.

    The import directory is a list of descriptors, one per library, and every descriptor names the library it is about. The list ends with a descriptor that is all zeroes, and an image that imports nothing has no directory at all.

    A file that is not a PE image imports nothing, and so does a file whose import directory does not make sense: the names that cannot be read are left out rather than guessed at.

    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.

    studio_asset_name

    fn studio_asset_name() -> String

    The name of the Studio asset in this version's release.

    studio_dir

    fn studio_dir() -> String

    Where a downloaded Studio lives. The directory is versioned, so a newer CLI does not silently start an older window.

    studio_release_url

    fn studio_release_url() -> String

    Where Studio comes from. A mirror or an air-gapped install can override the whole URL; the default is the release that carries this CLI.

    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.

    wants_gui

    fn wants_gui(argv : ArrayView[String]) -> Bool

    Is the GUI being asked for? The switch may sit anywhere on the command line, so lunapack --GUI ... and lunapack ... --GUI both work.

    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.