// LunaPack CLI entry point.
//
// Two equivalent front ends are supported.
//
// 1. The `moonx` style, used when the package is published to Mooncakes:
//
// moonx riantr/LunaPack@latest \
// --input-exe=[input-exe-path] \
// --output-exe=[output-exe-path] \
// --Files=[Pack-Folder]
//
// `--input-exe` is the application executable to distribute, `--Files` is a
// folder of extra files to bundle with it (repeatable), and `--output-exe`
// is the produced single file. The result is the input executable with a
// LunaPack archive appended, so it stays runnable while carrying the
// bundled files.
//
// 2. Subcommands, which expose the full feature set:
//
// lunapack pack -i -o [options]
// lunapack unpack -i -o [--skip-scripts]
// lunapack verify
// lunapack info
//
// Flags accept both `--flag=value` and `--flag value`.
//
// NOTE: in this MoonBit version `main` must return `Unit`, so process exit
// codes cannot be set. Status is reported on stdout:
// "VERIFY OK" / "VERIFY FAILED" / "error: "
///|
/// One `--flag value` pair.
type Flag = (String, String)
///|
/// Parse `-f value`, `--flag=value` and `--flag value` pairs from `start`.
///
/// The leading dashes are stripped, so `-i` and `--input` both yield the key
/// "i" and "input" respectively.
fn parse_flags(args : ArrayView[String], start : Int32) -> Array[Flag] {
let out = []
let mut i = start
while i < args.length() {
let a = args[i]
if a.has_prefix("-") && a.length() > 1 {
// Skip the leading dashes.
let mut key_start : Int32 = 0
while key_start < a.length() && a[key_start] == '-' {
key_start = key_start + 1
}
match a.find("=") {
Some(pos) => {
out.push((a[key_start:pos].to_owned(), a[pos + 1:].to_owned()))
i = i + 1
}
None => {
let key = a[key_start:].to_owned()
if i + 1 < args.length() && !args[i + 1].has_prefix("-") {
out.push((key, args[i + 1]))
i = i + 2
} else {
out.push((key, "true"))
i = i + 1
}
}
}
} else {
i = i + 1
}
}
out
}
///|
/// The value of the first occurrence of `key`, or "".
fn flag_value(flags : Array[Flag], key : String) -> String {
for f in flags {
if f.0 == key {
return f.1
}
}
""
}
///|
/// Is `key` present at all?
///
/// A flag that switches something on does not need a value, and the parser
/// cannot tell a bare `--flag` from `--flag value`, so the presence of the
/// flag is what counts.
fn flag_has(flags : Array[Flag], key : String) -> Bool {
for f in flags {
if f.0 == key {
return true
}
}
false
}
///|
/// The value of the first of `keys` that is present, or "".
fn flag_value_any(flags : Array[Flag], keys : Array[String]) -> String {
for k in keys {
let v = flag_value(flags, k)
if v != "" {
return v
}
}
""
}
///|
/// Every value of `key`, in order.
fn flag_values(flags : Array[Flag], key : String) -> Array[String] {
let out = []
for f in flags {
if f.0 == key {
out.push(f.1)
}
}
out
}
///|
/// The flags the tool knows.
///
/// A flag that is not in this list is a mistake, and a mistake that is
/// silently ignored is the worst kind: the command then does something
/// else than what was asked, and says nothing about it. `--input-file` for
/// instance is not `--input-exe`, and taking it for one would leave the
/// output path empty.
fn known_flags() -> Array[String] {
[
"Files", "arch", "compression", "compression-method", "encryption", "encryption-method",
"expected-sha256", "extracted", "i", "input", "input-exe", "input-file", "launcher",
"name", "o", "output", "output-exe", "output-file", "passphrase", "passphrase-file",
"post-install", "pre-install", "public-key", "secret-key", "signature", "skip-scripts",
"version",
]
}
///|
/// The names of the flags of `flags` that the tool does not know.
///
/// The names come back in the order they were given, and each of them only
/// once.
fn unknown_flags(flags : Array[Flag]) -> Array[String] {
let known = known_flags()
let out = []
for f in flags {
if !known.contains(f.0) && !out.contains(f.0) {
out.push(f.0)
}
}
out
}
///|
/// The message that names the flags the tool does not know.
///
/// The flags the tool does know are named with their dashes, so that the
/// list can be read and used as it is.
fn unknown_flags_message(flags : Array[Flag]) -> String {
let names = []
for f in unknown_flags(flags) {
names.push("--" + f)
}
let all = []
for f in known_flags() {
all.push("--" + f)
}
if names.length() == 1 {
"unknown flag " + names[0] + " (the flags are: " + all.join(" ") + ")"
} else {
"unknown flags " +
names.join(" ") +
" (the flags are: " +
all.join(" ") +
")"
}
}
///|
/// Fail when a flag of `flags` is not one the tool knows.
fn check_flags(flags : Array[Flag]) -> Unit raise {
if unknown_flags(flags).length() > 0 {
fail(unknown_flags_message(flags))
}
}
///|
/// The path the output of a command goes to, or an error when it is empty.
///
/// An empty path is what a mistyped flag leaves behind, and a command that
/// writes to it fails with a message about the file system instead of about
/// the flag that was wrong.
fn require_output(output : String, flag_name : String) -> String raise {
if output == "" {
fail("the output path is empty: " + flag_name + " must name a file")
}
output
}
///|
/// Read the whole content of a file as UTF-8 text.
async fn read_text_file(path : String) -> String {
let data = @fs.read_binary(path)
utf8_bytes_to_string(data)
}
///|
/// The passphrase of the encryption, from a file or from the command line.
///
/// The file is the better choice: a passphrase that is given on the command
/// line can be seen by every process on the machine.
async fn read_passphrase(flags : Array[Flag]) -> String {
let file = flag_value(flags, "passphrase-file")
if file != "" {
let text = read_text_file(file) catch {
err => fail("cannot read the passphrase file: " + err.to_string())
}
// The line ending at the end of the file is not part of the passphrase.
let mut end = text.length()
while end > 0 {
let c = text[end - 1]
if c == '\n' || c == '\r' || c == ' ' || c == '\t' {
end = end - 1
} else {
break
}
}
return text[0:end].to_owned()
}
flag_value(flags, "passphrase")
}
///|
/// The encryption of the package, from the flags.
///
/// Returns None when the package is not to be encrypted.
async fn encryption_options(flags : Array[Flag]) -> EncryptionOptions? {
let mut kind = flag_value(flags, "encryption-method")
if kind == "" {
kind = flag_value(flags, "encryption")
}
if kind == "" || kind == "none" {
return None
}
if kind != METHOD_X25519 && kind != METHOD_PASSPHRASE {
fail("--encryption-method must be x25519 or passphrase")
}
let public_key = flag_value(flags, "public-key")
let passphrase = if kind == METHOD_PASSPHRASE {
read_passphrase(flags)
} else {
""
}
if kind == METHOD_X25519 && public_key == "" {
fail("--encryption-method x25519 needs --public-key")
}
if kind == METHOD_PASSPHRASE && passphrase == "" {
fail("--encryption-method passphrase needs --passphrase-file")
}
Some(
new_encryption_options(kind, public_key, passphrase, PASSPHRASE_ITERATIONS),
)
}
///|
/// The key of an encrypted package, from the flags.
///
/// Returns None when the package is not encrypted.
async fn package_key(flags : Array[Flag], m : Manifest) -> PackageKey? {
match m.encryption {
None => {
// The package says that it is not encrypted. A key that was given
// anyway says that the package is not the one it says it is, so it is
// refused instead of being opened.
if flag_value(flags, "secret-key") != "" ||
flag_value(flags, "passphrase-file") != "" {
fail(
"the package says that it is not encrypted, but a key was given: the package was changed",
)
}
None
}
Some(info) => {
// The passphrase is needed in two places: for a package that is sealed
// with one, and for a secret key file that one protects. The same
// flag serves both.
let passphrase = read_passphrase(flags)
let secret = flag_value(flags, "secret-key")
if info.method_name != METHOD_PASSPHRASE && secret == "" {
fail("the package is encrypted: it needs --secret-key")
}
Some(recover_encryption(info, secret, passphrase))
}
}
}
///|
/// Build the pack options shared by both front ends.
async fn build_options(flags : Array[Flag]) -> PackOptions {
let pre_path = flag_value(flags, "pre-install")
let post_path = flag_value(flags, "post-install")
let pre = if pre_path == "" {
None
} else {
// An unreadable script is an error. Returning empty options would throw
// away every other flag the caller gave, and the package would be built
// with defaults while the command looked as though it had succeeded.
let body = read_text_file(pre_path) catch {
err => fail("cannot read --pre-install script: " + err.to_string())
}
Some(new_script(script_platform_of(pre_path), body))
}
let post = if post_path == "" {
None
} else {
let body = read_text_file(post_path) catch {
err => fail("cannot read --post-install script: " + err.to_string())
}
Some(new_script(script_platform_of(post_path), body))
}
// The compression method; `--compression` is the older name of the flag.
let mut compression = flag_value(flags, "compression-method")
if compression == "" {
compression = flag_value(flags, "compression")
}
new_pack_options(
flag_value(flags, "name"),
flag_value(flags, "version"),
flag_value(flags, "os"),
flag_value(flags, "arch"),
pre,
post,
if compression == "" {
"auto"
} else {
compression
},
)
}
///|
fn usage() -> Unit {
println("LunaPack - application distribution packer (MoonBit)")
println("")
println("usage (moonx):")
println(" moonx riantr/LunaPack@latest --input-exe=[input-exe-path] \\")
println(" --output-exe=[output-exe-path] --Files=[Pack-Folder]")
println("")
println("usage (native):")
println(" lunapack pack -i -o [options]")
println(
" lunapack bundle -i -o [--Files ]... [options]",
)
println(" lunapack unpack -i -o [--skip-scripts]")
println(" lunapack verify [--extracted ]")
println(" lunapack sign --secret-key [--output ]")
println(" lunapack info ")
println(" lunapack keygen --public-key --secret-key ")
println("")
println("options:")
println(" --input-exe application executable to distribute")
println(" --output-exe produced single-file bundle")
println(" --Files folder of files to bundle (repeatable)")
println(" --input-file another name for --input-exe")
println(" --output-file another name for --output-exe")
println(" --name package name (default: input dir name)")
println(" --version package version (default: 0.1.0)")
println(" --os target OS tag (default: detected)")
println(" --arch target arch tag (default: detected)")
println(" --pre-install script executed before extraction")
println(" --post-install script executed after extraction")
println(" --compression-method none | gzip | lz4 | auto (default: auto)")
println(
" --launcher launcher for the single file (default: this one)",
)
println("")
println("verification:")
println(
" --signature signature the host published for the package",
)
println(
" --public-key public key of the host that signed it (ed25519)",
)
println(" --expected-sha256 digest the host published for the package")
println("")
println("encryption:")
println(" --encryption-method x25519 | passphrase")
println(" --public-key public key of the recipient (x25519)")
println(" --secret-key secret key of the recipient")
println(" --passphrase-file file that holds the passphrase")
println(
" --passphrase the passphrase (visible in the process list)",
)
}
///|
/// The `moonx` style front end.
async fn do_flags(flags : Array[Flag]) -> Unit {
let input_exe = flag_value_any(flags, ["input-exe", "input-file"])
let output_exe = flag_value_any(flags, ["output-exe", "output-file"])
let file_dirs = flag_values(flags, "Files")
if input_exe == "" && output_exe == "" && file_dirs.length() == 0 {
usage()
return
}
try {
check_flags(flags)
let base = build_options(flags)
let name = if base.name == "" {
if input_exe == "" {
"app"
} else {
basename(input_exe)
}
} else {
base.name
}
let opts = with_defaults(
base,
name,
if base.version == "" {
"0.1.0"
} else {
base.version
},
if base.os == "" {
host_platform()
} else {
base.os
},
if base.arch == "" {
default_arch()
} else {
base.arch
},
)
let opts = match encryption_options(flags) {
Some(e) => with_encryption(opts, e)
None => opts
}
if input_exe == "" {
// No executable given: write a plain archive instead.
let first = if file_dirs.length() > 0 { file_dirs[0] } else { "." }
pack(first, require_output(output_exe, "--output-exe"), opts)
} else {
bundle(
input_exe,
require_output(output_exe, "--output-exe"),
file_dirs,
opts,
flag_value(flags, "launcher"),
)
}
} catch {
err => {
println("error: " + err.to_string())
exit_with(1)
}
}
}
///|
/// The `pack` subcommand.
async fn do_pack(args : ArrayView[String]) -> Unit {
let flags = parse_flags(args, 1)
check_flags(flags) catch {
err => {
println("error: " + err.to_string())
exit_with(1)
return
}
}
let input = flag_value_any(flags, ["input", "i"])
let output = flag_value_any(flags, ["output", "o"])
if input == "" || output == "" {
println("error: pack requires -i and -o ")
usage()
exit_with(1)
return
}
let compression = flag_value_any(flags, ["compression-method", "compression"])
if compression != "" &&
compression != "none" &&
compression != "gzip" &&
compression != "lz4" &&
compression != "auto" {
println("error: --compression-method must be none, gzip, lz4 or auto")
exit_with(1)
return
}
try {
let base = build_options(flags)
let opts = with_defaults(
base,
if base.name == "" {
basename(input)
} else {
base.name
},
if base.version == "" {
"0.1.0"
} else {
base.version
},
if base.os == "" {
host_platform()
} else {
base.os
},
if base.arch == "" {
default_arch()
} else {
base.arch
},
)
let opts = match encryption_options(flags) {
Some(e) => with_encryption(opts, e)
None => opts
}
pack(input, output, opts)
} catch {
err => {
println("error: " + err.to_string())
exit_with(1)
}
}
}
///|
/// The `bundle` subcommand.
///
/// It makes a single file that starts on its own: the program, the files it
/// needs and a launcher that unpacks them and starts the program.
async fn do_bundle(args : ArrayView[String]) -> Unit {
let flags = parse_flags(args, 1)
check_flags(flags) catch {
err => {
println("error: " + err.to_string())
exit_with(1)
return
}
}
let input = flag_value_any(flags, ["input", "i"])
let output = flag_value_any(flags, ["output", "o"])
if input == "" || output == "" {
println("error: bundle requires -i and -o ")
usage()
exit_with(1)
return
}
try {
let base = build_options(flags)
let opts = with_defaults(
base,
if base.name == "" {
basename(input)
} else {
base.name
},
if base.version == "" {
"0.1.0"
} else {
base.version
},
if base.os == "" {
host_platform()
} else {
base.os
},
if base.arch == "" {
default_arch()
} else {
base.arch
},
)
let opts = match encryption_options(flags) {
Some(e) => with_encryption(opts, e)
None => opts
}
bundle(
input,
output,
flag_values(flags, "Files"),
opts,
flag_value(flags, "launcher"),
)
} catch {
err => {
println("error: " + err.to_string())
exit_with(1)
}
}
}
///|
/// The `unpack` subcommand.
async fn do_unpack(args : ArrayView[String]) -> Unit {
let flags = parse_flags(args, 1)
check_flags(flags) catch {
err => {
println("error: " + err.to_string())
exit_with(1)
return
}
}
let input = flag_value_any(flags, ["input", "i"])
let output = flag_value_any(flags, ["output", "o"])
if input == "" || output == "" {
println("error: unpack requires -i and -o ")
usage()
exit_with(1)
return
}
let skip = flag_has(flags, "skip-scripts")
try {
// A signature or a digest that the host published is checked before the
// package is opened at all. The flags are honoured here as well as in
// `verify`, because a flag that is accepted and then ignored is worse
// than a flag that is refused: the user believes the package was
// checked, and it was not.
let signature_path = flag_value(flags, "signature")
let public_path = flag_value(flags, "public-key")
let expected = flag_value(flags, "expected-sha256")
if signature_path != "" {
let text = read_text_file(signature_path) catch {
err => fail("cannot read the signature file: " + err.to_string())
}
let signature = match signature_from_text(text) {
Some(s) => s
None => fail("the signature file is not one")
}
if public_path == "" {
fail(
"a signature is checked against a public key: --public-key is missing",
)
}
let public = load_signing_public_key(public_path)
if !verify_package_signature(input, signature, public) {
fail(
"the signature of the package does not hold: the package is refused",
)
}
println("the signature of the package holds")
}
if expected != "" {
if !verify_package_digest(input, expected) {
fail(
"the digest of the package is not the one that was expected: the package is refused",
)
}
println("the digest of the package is the one that was expected")
}
let m = read_package_manifest(input)
let key = package_key(flags, m)
unpack(input, output, !skip, key)
} catch {
err => {
println("error: " + err.to_string())
exit_with(1)
}
}
}
///|
/// The `verify` subcommand.
///
/// `verify ` checks the package itself. `verify --extracted
/// ` also checks the files that are already in `dir` against the
/// fingerprints of the package.
///
/// `verify --signature --public-key ` checks the
/// signature that the host published next to the package, and
/// `verify --expected-sha256 ` checks the digest that the
/// host published. Both are the material of the host, so they are checked
/// before the package is opened at all.
async fn do_verify(args : ArrayView[String]) -> Unit {
if args.length() < 2 {
println("error: verify requires a package path")
usage()
exit_with(1)
return
}
try {
let flags = parse_flags(args, 1)
check_flags(flags)
let signature_path = flag_value(flags, "signature")
let public_path = flag_value(flags, "public-key")
let expected = flag_value(flags, "expected-sha256")
if signature_path != "" {
let text = read_text_file(signature_path) catch {
err => fail("cannot read the signature file: " + err.to_string())
}
let signature = match signature_from_text(text) {
Some(s) => s
None => fail("the signature file is not one")
}
if public_path == "" {
fail(
"a signature is checked against a public key: --public-key is missing",
)
}
let public = load_signing_public_key(public_path)
if !verify_package_signature(args[1], signature, public) {
println("VERIFY FAILED")
exit_with(1)
return
}
println("the signature of the package holds")
}
if expected != "" {
if !verify_package_digest(args[1], expected) {
println("VERIFY FAILED")
exit_with(1)
return
}
println("the digest of the package is the one that was expected")
}
let extracted = flag_value(flags, "extracted")
let m = read_package_manifest(args[1])
let key = package_key(flags, m)
let ok = verify(args[1], key)
// The package and the extracted directory are two separate checks, and
// both of them have to hold: a package that is damaged is not made whole
// by an extracted directory that is intact.
let extracted_ok = if extracted == "" {
true
} else {
verify_extracted_dir(args[1], extracted)
}
if ok && extracted_ok {
println("VERIFY OK")
} else {
println("VERIFY FAILED")
exit_with(1)
}
} catch {
err => {
println("error: " + err.to_string())
exit_with(1)
}
}
}
///|
/// The `sign` subcommand.
///
/// It signs a package with the secret key and writes the signature next to
/// it, so that whoever holds the public key can tell that the package is
/// the one that was published.
async fn do_sign(args : ArrayView[String]) -> Unit {
if args.length() < 2 {
println("error: sign requires a package path")
usage()
exit_with(1)
return
}
let flags = parse_flags(args, 1)
check_flags(flags) catch {
err => {
println("error: " + err.to_string())
exit_with(1)
return
}
}
let secret_path = flag_value(flags, "secret-key")
if secret_path == "" {
println("error: sign requires --secret-key ")
usage()
exit_with(1)
return
}
let output = flag_value(flags, "output")
try {
let passphrase = read_passphrase(flags)
let secret = load_secret_key(secret_path, passphrase)
let signature = sign_package(args[1], secret)
let text = signature_to_text(signature)
if output == "" {
println(text)
} else {
@fs.write_text(output, text)
println("wrote the signature to " + output)
}
} catch {
err => {
println("error: " + err.to_string())
exit_with(1)
}
}
}
///|
/// The `keygen` subcommand.
///
/// It makes a pair of keys: the public key goes to the machine that seals
/// the packages, the secret key to the machine that opens them.
async fn do_keygen(args : ArrayView[String]) -> Unit {
let flags = parse_flags(args, 1)
check_flags(flags) catch {
err => {
println("error: " + err.to_string())
exit_with(1)
return
}
}
let public_path = flag_value(flags, "public-key")
let secret_path = flag_value(flags, "secret-key")
if public_path == "" || secret_path == "" {
println(
"error: keygen requires --public-key and --secret-key ",
)
usage()
exit_with(1)
return
}
try {
let secret = random_bytes(32)
let pair = public_key_pair(secret)
save_public_key_pair(public_path, pair.encryption, pair.signing)
let passphrase = read_passphrase(flags)
if passphrase == "" {
save_secret_key_raw(secret_path, secret)
} else {
save_secret_key(secret_path, secret, passphrase)
}
println("wrote the public key to " + public_path)
println("wrote the secret key to " + secret_path)
} catch {
err => {
println("error: " + err.to_string())
exit_with(1)
}
}
}
///|
/// The `info` subcommand.
async fn do_info(args : ArrayView[String]) -> Unit {
if args.length() < 2 {
println("error: info requires a package path")
usage()
exit_with(1)
return
}
try {
let flags = parse_flags(args, 1)
check_flags(flags)
let m = read_package_manifest(args[1])
println("name: " + m.name)
println("version: " + m.version)
println("format: v" + m.format_version.to_string())
println("os/arch: " + m.os + " / " + m.arch)
println("checksum: " + m.checksum_algo)
println("compression: " + m.compression)
println("created_at: " + m.created_at)
println(
"main program: " +
(if m.main_program == "" { "-" } else { m.main_program }),
)
println(
"scripts: pre=" +
(if m.pre_install is Some(_) { "yes" } else { "no" }) +
" post=" +
(if m.post_install is Some(_) { "yes" } else { "no" }),
)
println(
"encryption: " +
(match m.encryption {
Some(e) => e.method_name
None => "none"
}),
)
println("entries: " + m.entries.length().to_string())
let mut total = 0L
let mut stored = 0L
for e in m.entries {
total = total + e.size
stored = stored + e.stored_size
println(
" " +
e.path +
" " +
e.size.to_string() +
" B [" +
e.codec +
"] " +
e.checksum,
)
if e.md5 != "" {
println(" md5 " + e.md5)
}
}
println("total size: " + total.to_string() + " B")
println("stored size: " + stored.to_string() + " B")
if total > 0 {
let pct = stored * 100L / total
println("ratio: " + pct.to_string() + "%")
}
} catch {
err => {
println("error: " + err.to_string())
exit_with(1)
}
}
}
///|
async fn main {
// A self-extracting bundle carries its payload inside this executable:
// start the bundled program instead of acting as a command line tool.
//
// A bundle that is damaged is refused: the error is not swallowed, because
// a bundle that cannot be unpacked is not a program without arguments.
let launched = try_launch_self_extracting() catch {
err => {
eprintln("LunaPack: " + err.to_string())
exit_with(1)
true
}
}
if launched {
return
}
let argv = @env.args()[1:]
if argv.length() == 0 {
usage()
return
}
let first = argv[0]
if first == "pack" {
do_pack(argv)
} else if first == "bundle" {
do_bundle(argv)
} else if first == "unpack" {
do_unpack(argv)
} else if first == "verify" {
do_verify(argv)
} else if first == "sign" {
do_sign(argv)
} else if first == "info" {
do_info(argv)
} else if first == "keygen" {
do_keygen(argv)
} else if first == "--help" || first == "-h" || first == "help" {
usage()
} else {
do_flags(parse_flags(argv, 0))
}
}