%%% @doc %%% ZX %%% %%% A general dependency and packaging tool that works together with the zomp %%% package manager. Given a project directory with a standard layout, zx can: %%% - Initialize your project for packaging and semver tracking under zomp. %%% - Add dependencies (recursively) defined in any zomp repository realm. %%% - Update dependencies (recursively) defined in any zomp repository realm. %%% - Remove dependencies. %%% - Update, upgrade or run any application from source that zomp tracks. %%% - Locally install packages from files and locally stored public keys. %%% - Build and run a local project from source using zomp dependencies. %%% @end -module(zx). -export([start/2, main/1]). -export_type([serial/0, package_id/0, package/0, realm/0, name/0, version/0, option/0, host/0, key_id/0, key_name/0, user/0, username/0, lower0_9/0, label/0, package_meta/0]). -record(s, {realm = "otpr" :: realm(), name = none :: none | name(), version = {z, z, z} :: version(), type = app :: app | lib, deps = [] :: [package_id()], serial = 0 :: serial(), dir = none :: none | file:filename(), socket = none :: none | gen_tcp:socket(), pid = none :: none | pid(), mon = none :: none | reference()}). -type state() :: #s{}. -type serial() :: integer(). -type package_id() :: {realm(), name(), version()}. -type package() :: {realm(), name()}. -type realm() :: lower0_9(). -type name() :: lower0_9(). -type version() :: {Major :: non_neg_integer() | z, Minor :: non_neg_integer() | z, Patch :: non_neg_integer() | z}. -type option() :: {string(), term()}. -type host() :: {string() | inet:ip_address(), inet:port_number()}. %-type keybin() :: {ID :: key_id(), % Type :: public | private, % DER :: binary()}. -type key_id() :: {realm(), key_name()}. -type key_name() :: label(). -type user() :: {realm(), username()}. -type username() :: label(). -type lower0_9() :: [$a..$z | $0..$9 | $_]. -type label() :: [$a..$z | $0..$9 | $_ | $- | $.]. -type package_meta() :: map(). -spec main(Args) -> no_return() when Args :: [string()]. %% @private %% The automatically exposed function initially called by escript to kick things off. %% Args is a list of command-line provided arguments, all presented as a list of strings %% delimited by whitespace in the shell. main(Args) -> ok = ensure_zomp_home(), start(Args). -spec start(Args) -> no_return() when Args :: [string()]. %% Dispatch work functions based on the nature of the input arguments. start(["help"]) -> usage_exit(0); start(["run", PackageString | Args]) -> run(PackageString, Args); start(["init", "app", PackageString]) -> PackageID = package_id(PackageString), initialize(app, PackageID); start(["init", "lib", PackageString]) -> PackageID = package_id(PackageString), initialize(lib, PackageID); start(["install", PackageFile]) -> assimilate(PackageFile); start(["set", "dep", PackageString]) -> PackageID = package_id(PackageString), set_dep(PackageID); start(["set", "version", VersionString]) -> set_version(VersionString); start(["list", "realms"]) -> list_realms(); start(["list", "packages", Realm]) -> ok = valid_realm(Realm), list_packages(Realm); start(["list", "versions", PackageName]) -> Package = string_to_package(PackageName), list_versions(Package); start(["list", "pending", PackageName]) -> Package = string_to_package(PackageName), list_pending(Package); start(["list", "resigns", Realm]) -> ok = valid_realm(Realm), list_resigns(Realm); start(["add", "realm", RealmFile]) -> add_realm(RealmFile); start(["add", "package", PackageName]) -> add_package(PackageName); start(["add", "packager", Package, UserName]) -> add_packager(Package, UserName); start(["add", "maintainer", Package, UserName]) -> add_maintainer(Package, UserName); start(["review", PackageString]) -> review(PackageString); start(["approve", PackageString]) -> PackageID = package_id(PackageString), approve(PackageID); start(["reject", PackageString]) -> PackageID = package_id(PackageString), reject(PackageID); start(["resign", PackageString]) -> resign(PackageString); start(["drop", "dep", PackageString]) -> PackageID = package_id(PackageString), drop_dep(PackageID); start(["drop", "key", KeyID]) -> drop_key(KeyID); start(["drop", "realm", Realm]) -> drop_realm(Realm); start(["verup", Level]) -> verup(Level); start(["runlocal" | Args]) -> run_local(Args); start(["package"]) -> {ok, TargetDir} = file:get_cwd(), package(TargetDir); start(["package", TargetDir]) -> case filelib:is_dir(TargetDir) of true -> package(TargetDir); false -> ok = log(error, "Target directory ~tp does not exist!", [TargetDir]), halt(22) end; start(["submit", PackageFile]) -> submit(PackageFile); start(["dialyze"]) -> dialyze(); start(["create", "user", Realm, Name]) -> create_user(Realm, Name); start(["create", "keypair"]) -> create_keypair(); start(["create", "plt"]) -> create_plt(); start(["create", "realm"]) -> create_realm(); start(["create", "realmfile", Realm]) -> create_realmfile(Realm); start(["create", "sysop"]) -> create_sysop(); start(_) -> usage_exit(22). %%% Execution of application -spec run(Identifier, Args) -> no_return() when Identifier :: string(), Args :: [string()]. %% @private %% Given a program Identifier and a list of Args, attempt to locate the program and its %% dependencies and run the program. This implies determining whether the program and %% its dependencies are installed, available, need to be downloaded, or are inaccessible %% given the current system condition (they could also be bogus, of course). The %% Identifier should be a valid PackageString of the form `realm-appname-version' %% where the realm and appname should follow standard realm and app package naming %% conventions and the version should be represented as a semver in string form (where %% ommitted elements of the version always default to whatever is most current). %% %% Once the target program is running, this process, (which will run with the registered %% name `zx') will sit in an `exec_wait' state, waiting for either a direct message from %% a child program or for calls made via zx_lib to assist in environment discovery. %% %% If there is a problem anywhere in the locating, discovery, building, and loading %% procedure the runtime will halt with an error message. run(Identifier, Args) -> MaybeID = package_id(Identifier), {ok, PackageID = {Realm, Name, Version}} = ensure_installed(MaybeID), ok = file:set_cwd(zomp_dir()), Dir = filename:join("lib", package_string(PackageID)), Meta = read_meta(Dir), Deps = maps:get(deps, Meta), ok = ensure_deps(Deps), State = #s{realm = Realm, name = Name, version = Version, dir = Dir, deps = Deps}, execute(State, Args). %%% Project initialization -spec initialize(Type, PackageID) -> no_return() when Type :: app | lib, PackageID :: package_id(). %% @private %% Initialize an application in the local directory based on the PackageID provided. %% This function does not care about the name of the current directory and leaves %% providing a complete, proper and accurate PackageID. %% This function will check the current `lib/' directory for zomp-style dependencies. %% If this is not the intended function or if there are non-compliant directory names %% in `lib/' then the project will need to be rearranged to become zomp compliant or %% the `deps' section of the resulting meta file will need to be manually updated. initialize(Type, PackageID) -> PackageString = package_string(PackageID), ok = log(info, "Initializing ~s...", [PackageString]), MetaList = [{package_id, PackageID}, {deps, []}, {type, Type}], Meta = maps:from_list(MetaList), ok = write_meta(Meta), ok = log(info, "Project ~tp initialized.", [PackageString]), Message = "NOTICE:~n" " This project is currently listed as having no dependencies.~n" " If this is not true then run `zx set dep DepID` for each current dependency.~n" " (run `zx help` for more information on usage)~n", ok = io:format(Message), halt(0). %%% Add a package from a local file -spec assimilate(PackageFile) -> PackageID when PackageFile :: file:filename(), PackageID :: package_id(). %% @private %% Receives a path to a file containing package data, examines it, and copies it to a %% canonical location under a canonical name, returning the PackageID of the package %% contents. assimilate(PackageFile) -> Files = extract_zrp(PackageFile), {ok, CWD} = file:get_cwd(), ok = file:set_cwd(zomp_dir()), {"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files), Meta = binary_to_term(MetaBin), PackageID = maps:get(package_id, Meta), TgzFile = namify_tgz(PackageID), {TgzFile, TgzData} = lists:keyfind(TgzFile, 1, Files), {KeyID, Signature} = maps:get(sig, Meta), {ok, PubKey} = loadkey(public, KeyID), ok = case public_key:verify(TgzData, sha512, Signature, PubKey) of true -> ZrpPath = filename:join("zrp", namify_zrp(PackageID)), erl_tar:create(ZrpPath, Files); false -> error_exit("Bad package signature: ~ts", [PackageFile], ?FILE, ?LINE) end, ok = file:set_cwd(CWD), Message = "~ts is now locally available.", ok = log(info, Message, [package_string(PackageID)]), halt(0). %%% Set dependency -spec set_dep(package_id()) -> no_return(). %% @private %% Set a specific dependency in the current project. If the project currently has a %% dependency on the same package then the version of that dependency is updated to %% reflect that in the PackageString argument. The AppString is permitted to be %% incomplete. Incomplete elements of the VersionString (if included) will default to %% the latest version available at the indicated level. set_dep(PackageID = {_, _, {X, Y, Z}}) when is_integer(X), is_integer(Y), is_integer(Z) -> Meta = read_meta(), Deps = maps:get(deps, Meta), case lists:member(PackageID, Deps) of true -> ok = log(info, "~ts is already a dependency", [package_string(PackageID)]), halt(0); false -> set_dep(PackageID, Deps, Meta) end; set_dep({Realm, Name, {z, z, z}}) -> Socket = connect_user(Realm), {ok, Version} = query_latest(Socket, {Realm, Name}), ok = disconnect(Socket), set_dep({Realm, Name, Version}); set_dep({Realm, Name, Version}) -> Socket = connect_user(Realm), {ok, Latest} = query_latest(Socket, {Realm, Name, Version}), ok = disconnect(Socket), set_dep({Realm, Name, Latest}). -spec set_dep(PackageID, Deps, Meta) -> no_return() when PackageID :: package_id(), Deps :: [package_id()], Meta :: [term()]. %% @private %% Given the PackageID, list of Deps and the current contents of the project Meta, add %% or update Deps to include (or update) Deps to reflect a dependency on PackageID, if %% such a dependency is not already present. Then write the project meta back to its %% file and exit. set_dep(PackageID = {Realm, Name, NewVersion}, Deps, Meta) -> ExistingPackageIDs = fun ({R, N, _}) -> {R, N} == {Realm, Name} end, NewDeps = case lists:partition(ExistingPackageIDs, Deps) of {[{Realm, Name, OldVersion}], Rest} -> Message = "Updating dep ~ts to ~ts", OldPackageString = package_string({Realm, Name, OldVersion}), NewPackageString = package_string({Realm, Name, NewVersion}), ok = log(info, Message, [OldPackageString, NewPackageString]), [PackageID | Rest]; {[], Deps} -> ok = log(info, "Adding dep ~ts", [package_string(PackageID)]), [PackageID | Deps] end, NewMeta = maps:put(deps, NewDeps, Meta), ok = write_meta(NewMeta), halt(0). -spec ensure_installed(PackageID) -> Result | no_return() when PackageID :: package_id(), Result :: {ok, ActualID :: package_id()}. %% @private %% Given a PackageID, check whether it is installed on the system, and if not, ensure %% that the package is either in the cache or can be downloaded. If all attempts at %% locating or acquiring the package fail, then exit with an error. ensure_installed(PackageID = {Realm, Name, Version}) -> case resolve_installed_version(PackageID) of exact -> {ok, PackageID}; {ok, Installed} -> {ok, {Realm, Name, Installed}}; not_found -> ensure_installed(Realm, Name, Version) end. -spec ensure_installed(Realm, Name, Version) -> Result | no_return() when Realm :: realm(), Name :: name(), Version :: version(), Result :: exact | {ok, package_id()} | not_found. %% @private %% Fetch and install the latest compatible version of the given package ID, whether %% the version indicator is complete, partial or blank. ensure_installed(Realm, Name, Version) -> Socket = connect_user(Realm), case query_latest(Socket, {Realm, Name, Version}) of {ok, LatestVersion} -> LatestID = {Realm, Name, LatestVersion}, ok = ensure_dep(Socket, LatestID), ok = disconnect(Socket), {ok, LatestID}; {error, bad_realm} -> PackageString = package_string({Realm, Name, Version}), ok = log(warning, "Bad realm: ~ts.", [PackageString]), halt(1); {error, bad_package} -> PackageString = package_string({Realm, Name, Version}), ok = log(warning, "Bad package: ~ts.", [PackageString]), halt(1); {error, bad_version} -> PackageString = package_string({Realm, Name, Version}), ok = log(warning, "Bad version: ~s.", [PackageString]), halt(1) end. -spec query_latest(Socket, Object) -> Result when Socket :: gen_tcp:socket(), Object :: package() | package_id(), Result :: {ok, version()} | {error, Reason}, Reason :: bad_realm | bad_package | bad_version. %% @private %% Queries the connected zomp node for the latest version of a package or package %% version (complete or incomplete version number). query_latest(Socket, {Realm, Name}) -> ok = send(Socket, {latest, Realm, Name}), receive {tcp, Socket, Bin} -> binary_to_term(Bin) after 5000 -> {error, timeout} end; query_latest(Socket, {Realm, Name, Version}) -> ok = send(Socket, {latest, Realm, Name, Version}), receive {tcp, Socket, Bin} -> binary_to_term(Bin) after 5000 -> {error, timeout} end. -spec resolve_installed_version(PackageID) -> Result when PackageID :: package_id(), Result :: not_found | exact | {ok, Installed :: version()}. %% @private %% Resolve the provided PackageID to the latest matching installed package directory %% version if one exists, returning a value that indicates whether an exact match was %% found (in the case of a full version input), a version matching a partial version %% input was found, or no match was found at all. resolve_installed_version(PackageID) -> PackageString = package_string(PackageID), Pattern = PackageString ++ "*", case filelib:wildcard(Pattern, "lib") of [] -> not_found; [PackageString] -> exact; [Dir] -> {_, _, Version} = package_id(Dir), {ok, Version}; Dirs -> Dir = lists:last(lists:sort(Dirs)), {_, _, Version} = package_id(Dir), {ok, Version} end. ensure_deps(Deps) -> case scrub(Deps) of [] -> ok; Needed -> Partitioned = partition_by_realm(Needed), EnsureDeps = fun({Realm, Packages}) -> Socket = connect_user(Realm), ok = ensure_deps(Socket, Realm, Packages), ok = disconnect(Socket), log(info, "Disconnecting from realm: ~ts", [Realm]) end, lists:foreach(EnsureDeps, Partitioned) end. partition_by_realm(PackageIDs) -> PartitionMap = lists:foldl(fun partition_by_realm/2, #{}, PackageIDs), maps:to_list(PartitionMap). partition_by_realm({R, P, V}, M) -> maps:update_with(R, fun(Ps) -> [{P, V} | Ps] end, [{P, V}], M). ensure_deps(_, _, []) -> ok; ensure_deps(Socket, Realm, [{Name, Version} | Rest]) -> ok = ensure_dep(Socket, {Realm, Name, Version}), ensure_deps(Socket, Realm, Rest). -spec ensure_dep(gen_tcp:socket(), package_id()) -> ok | no_return(). %% @private %% Given an PackageID as an argument, check whether its package file exists in the %% system cache, and if not download it. Should return `ok' whenever the file is %% sourced, but exit with an error if it cannot locate or acquire the package. ensure_dep(Socket, PackageID) -> ZrpFile = filename:join("zrp", namify_zrp(PackageID)), ok = case filelib:is_regular(ZrpFile) of true -> ok; false -> fetch(Socket, PackageID) end, ok = install(PackageID), build(PackageID). %%% Set version -spec set_version(VersionString) -> no_return() when VersionString :: string(). %% @private %% Convert a version string to a new version, sanitizing it in the process and returning %% a reasonable error message on bad input. set_version(VersionString) -> NewVersion = case string_to_version(VersionString) of {_, _, z} -> Message = "'set version' arguments must be complete, ex: 1.2.3", ok = log(error, Message), halt(22); Version -> Version end, update_version(NewVersion). -spec update_version(Level) -> no_return() when Level :: major | minor | patch | VersionString, VersionString :: string(). % Of the form "Major.Minor.Patch" %% @private %% Update a project's `zomp.meta' file by either incrementing the indicated component, %% or setting the version number to the one specified in VersionString. %% This part of the procedure guards for the case when the zomp.meta file cannot be %% read for some reason. update_version(Arg) -> Meta = read_meta(), PackageID = maps:get(package_id, Meta), update_version(Arg, PackageID, Meta). -spec update_version(Level, PackageID, Meta) -> no_return() when Level :: major | minor | patch | version(), PackageID :: package_id(), Meta :: [{atom(), term()}]. %% @private %% Update a project's `zomp.meta' file by either incrementing the indicated component, %% or setting the version number to the one specified in VersionString. %% This part of the procedure does the actual update calculation, to include calling to %% convert the VersionString (if it is passed) to a `version()' type and check its %% validity (or halt if it is a bad string). update_version(major, {Realm, Name, OldVersion = {Major, _, _}}, OldMeta) -> NewVersion = {Major + 1, 0, 0}, update_version(Realm, Name, OldVersion, NewVersion, OldMeta); update_version(minor, {Realm, Name, OldVersion = {Major, Minor, _}}, OldMeta) -> NewVersion = {Major, Minor + 1, 0}, update_version(Realm, Name, OldVersion, NewVersion, OldMeta); update_version(patch, {Realm, Name, OldVersion = {Major, Minor, Patch}}, OldMeta) -> NewVersion = {Major, Minor, Patch + 1}, update_version(Realm, Name, OldVersion, NewVersion, OldMeta); update_version(NewVersion, {Realm, Name, OldVersion}, OldMeta) -> update_version(Realm, Name, OldVersion, NewVersion, OldMeta). -spec update_version(Realm, Name, OldVersion, NewVersion, OldMeta) -> no_return() when Realm :: realm(), Name :: name(), OldVersion :: version(), NewVersion :: version(), OldMeta :: [{atom(), term()}]. %% @private %% Update a project's `zomp.meta' file by either incrementing the indicated component, %% or setting the version number to the one specified in VersionString. %% This part of the procedure updates the meta and does the final write, if the write %% turns out to be possible. If successful it will indicate to the user what was %% changed. update_version(Realm, Name, OldVersion, NewVersion, OldMeta) -> PackageID = {Realm, Name, NewVersion}, NewMeta = maps:put(package_id, PackageID, OldMeta), ok = write_meta(NewMeta), ok = log(info, "Version changed from ~s to ~s.", [version_to_string(OldVersion), version_to_string(NewVersion)]), halt(0). %%% List Functions -spec list_realms() -> no_return(). %% @private %% List all currently configured realms. The definition of a "configured realm" is a %% realm for which a .realm file exists in ~/.zomp/. The realms will be printed to %% stdout and the program will exit. list_realms() -> Pattern = filename:join(zomp_dir(), "*.realm"), RealmFiles = filelib:wildcard(Pattern), Realms = [filename:basename(RF, ".realm") || RF <- RealmFiles], ok = lists:foreach(fun(R) -> io:format("~ts~n", [R]) end, Realms), halt(0). -spec list_packages(realm()) -> no_return(). %% @private %% Contact the indicated realm and query it for a list of registered packages and print %% them to stdout. list_packages(Realm) -> Socket = connect_user(Realm), ok = send(Socket, {list, Realm}), case recv_or_die(Socket) of {ok, []} -> ok = log(info, "Realm ~tp has no packages available.", [Realm]), halt(0); {ok, Packages} -> Print = fun({R, N}) -> io:format("~ts-~ts~n", [R, N]) end, ok = lists:foreach(Print, Packages), halt(0) end. -spec list_versions(package()) -> no_return(). %% @private %% List the available versions of the package indicated. The user enters a string-form %% package name (such as "otpr-zomp") and the return values will be full package strings %% of the form "otpr-zomp-1.2.3", one per line printed to stdout. list_versions(Package = {Realm, Name}) -> ok = valid_package(Package), Socket = connect_user(Realm), ok = send(Socket, {list, Realm, Name}), case recv_or_die(Socket) of {ok, []} -> Message = "Package ~ts-~ts has no versions available.", ok = log(info, Message, [Realm, Name]), halt(0); {ok, Versions} -> Print = fun(Version) -> PackageString = package_string({Realm, Name, Version}), io:format("~ts~n", [PackageString]) end, ok = lists:foreach(Print, Versions), halt(0) end. -spec list_pending(package()) -> no_return(). %% @private %% List the versions of a package that are pending review. The package name is input by the %% user as a string of the form "otpr-zomp" and the output is a list of full package IDs, %% printed one per line to stdout (like "otpr-zomp-3.2.2"). list_pending(Package = {Realm, Name}) -> ok = valid_package(Package), Socket = connect_user(Realm), ok = send(Socket, {pending, Package}), case recv_or_die(Socket) of {ok, []} -> Message = "Package ~ts-~ts has no versions pending.", ok = log(info, Message, [Realm, Name]), halt(0); {ok, Versions} -> Print = fun(Version) -> PackageString = package_string({Realm, Name, Version}), io:format("~ts~n", [PackageString]) end, ok = lists:foreach(Print, Versions), halt(0) end. -spec list_resigns(realm()) -> no_return(). %% @private %% List the package ids of all packages waiting in the resign queue for the given realm, %% printed to stdout one per line. list_resigns(Realm) -> Socket = connect_auth_or_die(Realm), ok = send(Socket, {list_resigns, Realm}), case recv_or_die(Socket) of {ok, []} -> Message = "No packages pending signature in ~tp.", ok = log(info, Message, [Realm]), halt(0); {ok, PackageIDs} -> Print = fun(PackageID) -> PackageString = package_string(PackageID), io:format("~ts~n", [PackageString]) end, ok = lists:foreach(Print, PackageIDs), halt(0) end. -spec valid_package(package()) -> ok | no_return(). %% @private %% Test whether a package() type is a valid value or not. If not, halt execution with %% a non-zero error code, if so then return `ok'. valid_package({Realm, Name}) -> case {valid_lower0_9(Realm), valid_lower0_9(Name)} of {true, true} -> ok; {false, true} -> ok = log(error, "Invalid realm name: ~tp", [Realm]), halt(1); {true, false} -> ok = log(error, "Invalid package name: ~tp", [Name]), halt(1); {false, false} -> ok = log(error, "Invalid realm ~tp and package ~tp", [Realm, Name]), halt(1) end. -spec string_to_package(string()) -> package() | no_return(). %% @private %% Convert a string to a package() type if possible. If not then halt the system. string_to_package(String) -> case string:lexemes(String, "-") of [Realm, Name] -> Package = {Realm, Name}, ok = valid_package(Package), Package; _ -> ok = log(error, "Bad package name."), halt(1) end. -spec valid_realm(realm()) -> ok | no_return(). %% @private %% Test whether a realm name is a valid realm() type (that is, a lower0_9()) or not. If not, %% halt execution with a non-zero error code, if so then return `ok'. valid_realm(Realm) -> case valid_lower0_9(Realm) of true -> ok; false -> ok = log(error, "Bad realm name."), halt(1) end. %%% Add realm -spec add_realm(Path) -> no_return() when Path :: file:filename(). add_realm(Path) -> case file:read_file(Path) of {ok, Data} -> Digest = crypto:hash(sha512, Data), Text = integer_to_list(binary:decode_unsigned(Digest, big), 16), ok = log(info, "SHA512 of ~ts: ~ts", [Path, Text]), add_realm(Path, Data); {error, enoent} -> ok = log(warning, "FAILED: ~ts does not exist.", [Path]), halt(1); {error, eisdir} -> ok = log(warning, "FAILED: ~ts is a directory, not a realm file.", [Path]), halt(1) end. -spec add_realm(Path, Data) -> no_return() when Path :: file:filename(), Data :: binary(). add_realm(Path, Data) -> case erl_tar:extract({binary, Data}, [compressed, {cwd, zomp_dir()}]) of ok -> {Realm, _} = string:take(filename:basename(Path), ".", true), ok = log(info, "Realm ~ts is now visible to this system.", [Realm]), halt(0); {error, invalid_tar_checksum} -> ok = log(warning, "FAILED: ~ts is not a valid realm file.", [Path]), halt(1); {error, eof} -> ok = log(warning, "FAILED: ~ts is not a valid realm file.", [Path]), halt(1) end. -spec add_package(PackageName) -> no_return() when PackageName :: package(). add_package(PackageName) -> ok = file:set_cwd(zomp_dir()), case string:lexemes(PackageName, "-") of [Realm, Name] -> case {valid_lower0_9(Realm), valid_lower0_9(Name)} of {true, true} -> add_package(Realm, Name); {false, true} -> ok = log(warning, "Invalid realm name: ~tp", [Realm]), halt(1); {true, false} -> ok = log(warning, "Invalid package name: ~tp", [Name]), halt(1); {false, false} -> ok = log(warning, "Invalid realm and package names."), halt(1) end; _ -> ok = log(warning, "Name ~tp is not a valid package name.", [PackageName]), halt(1) end. -spec add_package(Realm, Name) -> no_return() when Realm :: realm(), Name :: name(). %% @private %% This sysop-only command can add a package to a realm operated by the caller. add_package(Realm, Name) -> Socket = connect_auth_or_die(Realm), ok = send(Socket, {add_package, {Realm, Name}}), ok = recv_or_die(Socket), ok = log(info, "\"~ts-~ts\" added successfully.", [Realm, Name]), halt(0). add_packager(Package, UserName) -> ok = log(info, "Would add ~ts to packagers for ~160tp now.", [UserName, Package]), halt(0). add_maintainer(Package, UserName) -> ok = log(info, "Would add ~ts to maintainer for ~160tp now.", [UserName, Package]), halt(0). review(PackageString) -> PackageID = {Realm, _, _} = package_id(PackageString), Socket = connect_auth_or_die(Realm), ok = send(Socket, {review, PackageID}), {ok, ZrpBin} = recv_or_die(Socket), ok = send(Socket, ok), ok = disconnect(Socket), {ok, Files} = erl_tar:extract({binary, ZrpBin}, [memory]), {"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files), Meta = binary_to_term(MetaBin, [safe]), PackageID = maps:get(package_id, Meta), {KeyID, Signature} = maps:get(sig, Meta), {ok, PubKey} = loadkey(public, KeyID), TgzFile = PackageString ++ ".tgz", {TgzFile, TgzData} = lists:keyfind(TgzFile, 1, Files), ok = verify(TgzData, Signature, PubKey), ok = case file:make_dir(PackageString) of ok -> log(info, "Will unpack to directory ./~ts", [PackageString]); {error, Error} -> Message = "Creating dir ./~ts failed with ~ts. Aborting.", ok = log(error, Message, [PackageString, Error]), halt(1) end, ok = erl_tar:extract({binary, TgzData}, [compressed, {cwd, PackageString}]), ok = log(info, "Unpacked and awaiting inspection."), halt(0). approve(PackageID = {Realm, _, _}) -> Socket = connect_auth_or_die(Realm), ok = send(Socket, {approve, PackageID}), ok = recv_or_die(Socket), ok = log(info, "ok"), halt(0). reject(PackageID = {Realm, _, _}) -> Socket = connect_auth_or_die(Realm), ok = send(Socket, {reject, PackageID}), ok = recv_or_die(Socket), ok = log(info, "ok"), halt(0). resign(PackageString) -> PackageID = {Realm, _, _} = package_id(PackageString), RealmConf = load_realm_conf(Realm), {package_keys, PackageKeys} = lists:keyfind(package_keys, 1, RealmConf), KeySelection = [{K, {R, K}} || {R, K} <- [element(1, K) || K <- PackageKeys]], PackageKeyID = select(KeySelection), {ok, PackageKey} = loadkey(private, PackageKeyID), Socket = connect_auth_or_die(Realm), ok = send(Socket, {resign, PackageID}), {ok, ZrpBin} = recv_or_die(Socket), {ok, Files} = erl_tar:extract({binary, ZrpBin}, [memory]), {"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files), Meta = binary_to_term(MetaBin, [safe]), PackageID = maps:get(package_id, Meta), {KeyID, Signature} = maps:get(sig, Meta), {ok, PubKey} = loadkey(public, KeyID), TgzFile = PackageString ++ ".tgz", {TgzFile, TgzData} = lists:keyfind(TgzFile, 1, Files), ok = verify(TgzData, Signature, PubKey), ReSignature = public_key:sign(TgzData, sha512, PackageKey), FinalMeta = maps:put(sig, {PackageKeyID, ReSignature}, Meta), NewMetaBin = term_to_binary(FinalMeta), NewFiles = lists:keystore("zomp.meta", 1, Files, {"zomp.meta", NewMetaBin}), ResignedZrp = PackageString ++ ".zrp.resign", ok = erl_tar:create(ResignedZrp, NewFiles), {ok, ResignedBin} = file:read_file(ResignedZrp), ok = gen_tcp:send(Socket, ResignedBin), ok = recv_or_die(Socket), ok = file:delete(ResignedZrp), ok = recv_or_die(Socket), ok = disconnect(Socket), ok = log(info, "Resigned ~ts", [PackageString]), halt(0). %%% Drop dependency -spec drop_dep(package_id()) -> no_return(). %% @private %% Remove the indicate dependency from the local project's zomp.meta record. drop_dep(PackageID) -> PackageString = package_string(PackageID), Meta = read_meta(), Deps = maps:get(deps, Meta), case lists:member(PackageID, Deps) of true -> NewDeps = lists:delete(PackageID, Deps), NewMeta = maps:put(deps, NewDeps, Meta), ok = write_meta(NewMeta), Message = "~ts removed from dependencies.", ok = log(info, Message, [PackageString]), halt(0); false -> ok = log(info, "~ts not found in dependencies.", [PackageString]), halt(0) end. %%% Drop key -spec drop_key(key_id()) -> no_return(). %% @private %% Given a KeyID, remove the related public and private keys from the keystore, if they %% exist. If not, exit with a message that no keys were found, but do not return an %% error exit value (this instruction is idempotent if used in shell scripts). drop_key({Realm, KeyName}) -> ok = file:set_cwd(zomp_dir()), Pattern = filename:join([zomp_dir(), "key", Realm, KeyName ++ ".{key,pub}.der"]), case filelib:wildcard(Pattern) of [] -> ok = log(warning, "Key ~ts/~ts not found", [Realm, KeyName]), halt(0); Files -> ok = lists:foreach(fun file:delete/1, Files), ok = log(info, "Keyset ~ts/~ts removed", [Realm, KeyName]), halt(0) end. %%% Drop realm -spec drop_realm(realm()) -> no_return(). drop_realm(Realm) -> ok = file:set_cwd(zomp_dir()), RealmConf = realm_conf(Realm), case filelib:is_regular(RealmConf) of true -> Message = "~n" " WARNING: Are you SURE you want to remove realm ~ts?~n" " (Only \"Y\" will confirm this action.)~n", ok = io:format(Message, [Realm]), case get_input() of "Y" -> ok = file:delete(RealmConf), ok = drop_prime(Realm), ok = clear_keys(Realm), ok = log(info, "All traces of realm ~ts have been removed."), halt(0); _ -> ok = log(info, "Aborting."), halt(0) end; false -> ok = log(warning, "Realm conf ~ts not found.", [RealmConf]), clear_keys(Realm) end. -spec drop_prime(realm()) -> ok. drop_prime(Realm) -> Path = "zomp.conf", case file:consult(Path) of {ok, Conf} -> {managed, Primes} = lists:keyfind(managed, 1, Conf), NewPrimes = lists:delete(Realm, Primes), NewConf = lists:keystore(managed, 1, Primes, {managed, NewPrimes}), ok = write_terms(Path, NewConf), log(info, "Ensuring ~ts is not a prime in ~ts", [Realm, Path]); {error, enoent} -> ok end. -spec clear_keys(realm()) -> ok. clear_keys(Realm) -> KeyDir = filename:join([zomp_dir(), "key", Realm]), case filelib:is_dir(KeyDir) of true -> ok = log(info, "Wiping key dir ~ts", [KeyDir]), Keys = filelib:wildcard(KeyDir ++ "/**"), Delete = fun(K) -> file:delete(K) end, ok = lists:foreach(Delete, Keys), ok = file:del_dir(KeyDir), log(info, "Done!"); false -> log(warning, "Keydir ~ts not found", [KeyDir]) end. %%% Update version -spec verup(Level) -> no_return() when Level :: string(). %% @private %% Convert input string arguments to acceptable atoms for use in update_version/1. verup("major") -> update_version(major); verup("minor") -> update_version(minor); verup("patch") -> update_version(patch); verup(_) -> usage_exit(22). %%% Run local project -spec run_local(Args) -> no_return() when Args :: [term()]. %% @private %% Execute a local project from source from the current directory, satisfying dependency %% requirements via the locally installed zomp lib cache. The project must be %% initialized as a zomp project (it must have a valid `zomp.meta' file). %% %% The most common use case for this function is during development. Using zomp support %% via the local lib cache allows project authors to worry only about their own code %% and use zx commands to add or drop dependencies made available via zomp. run_local(Args) -> Meta = read_meta(), {Realm, Name, Version} = maps:get(package_id, Meta), Type = maps:get(type, Meta), Deps = maps:get(deps, Meta), ok = build(), {ok, Dir} = file:get_cwd(), ok = file:set_cwd(zomp_dir()), State = #s{realm = Realm, name = Name, version = Version, type = Type, deps = Deps, dir = Dir}, ok = ensure_deps(Deps), ok = file:set_cwd(Dir), execute(State, Args). -spec execute(State, Args) -> no_return() when State :: state(), Args :: [string()]. %% @private %% Gets all the target application's ducks in a row and launches them, then enters %% the exec_wait/1 loop to wait for any queries from the application. execute(State = #s{type = app, realm = Realm, name = Name, version = Version}, Args) -> true = register(zx, self()), ok = inets:start(), ok = log(info, "Starting ~ts", [package_string({Realm, Name, Version})]), AppMod = list_to_atom(Name), {ok, Apps} = application:ensure_all_started(AppMod), ok = log(info, "Started, ~tp", [Apps]), ok = pass_argv(AppMod, Args), exec_wait(State); execute(#s{type = lib, realm = Realm, name = Name, version = Version}, _) -> Message = "Lib ~ts is available on the system, but is not a standalone app.", PackageString = package_string({Realm, Name, Version}), ok = log(info, Message, [PackageString]), halt(0). -spec pass_argv(AppMod, Args) -> ok when AppMod :: module(), Args :: [string()]. %% @private %% Check whether the AppMod:accept_argv/1 is implemented. If so, pass in the %% command line arguments provided. pass_argv(AppMod, Args) -> case lists:member({accept_argv, 1}, AppMod:module_info(exports)) of true -> AppMod:accept_argv(Args); false -> ok end. %%% Package generation -spec package(TargetDir) -> no_return() when TargetDir :: file:filename(). %% @private %% Turn a target project directory into a package, prompting the user for appropriate %% key selection or generation actions along the way. package(TargetDir) -> ok = log(info, "Packaging ~ts", [TargetDir]), Meta = read_meta(TargetDir), {Realm, _, _} = maps:get(package_id, Meta), KeyDir = filename:join([zomp_dir(), "key", Realm]), ok = force_dir(KeyDir), Pattern = KeyDir ++ "/*.key.der", case [filename:basename(F, ".key.der") || F <- filelib:wildcard(Pattern)] of [] -> ok = log(info, "Need to generate key"), KeyID = prompt_keygen(), {ok, _, _} = generate_rsa(KeyID), package(KeyID, TargetDir); [KeyName] -> KeyID = {Realm, KeyName}, ok = log(info, "Using key: ~ts/~ts", [Realm, KeyName]), package(KeyID, TargetDir); KeyNames -> KeyName = select_string(KeyNames), package({Realm, KeyName}, TargetDir) end. -spec package(KeyID, TargetDir) -> no_return() when KeyID :: key_id(), TargetDir :: file:filename(). %% @private %% Accept a KeyPrefix for signing and a TargetDir containing a project to package and %% build a zrp package file ready to be submitted to a repository. package(KeyID, TargetDir) -> Meta = read_meta(TargetDir), PackageID = maps:get(package_id, Meta), true = element(1, PackageID) == element(1, KeyID), PackageString = package_string(PackageID), ZrpFile = PackageString ++ ".zrp", TgzFile = PackageString ++ ".tgz", ok = halt_if_exists(ZrpFile), ok = remove_binaries(TargetDir), {ok, Everything} = file:list_dir(TargetDir), DotFiles = filelib:wildcard(".*", TargetDir), Ignores = ["lib" | DotFiles], Targets = lists:subtract(Everything, Ignores), {ok, CWD} = file:get_cwd(), ok = file:set_cwd(TargetDir), ok = build(), Modules = [filename:basename(M, ".beam") || M <- filelib:wildcard("*.beam", "ebin")], ok = remove_binaries("."), ok = erl_tar:create(filename:join(CWD, TgzFile), Targets, [compressed]), ok = file:set_cwd(CWD), {ok, Key} = loadkey(private, KeyID), {ok, TgzBin} = file:read_file(TgzFile), Sig = public_key:sign(TgzBin, sha512, Key), Add = fun({K, V}, M) -> maps:put(K, V, M) end, FinalMeta = lists:foldl(Add, Meta, [{modules, Modules}, {sig, {KeyID, Sig}}]), ok = file:write_file("zomp.meta", term_to_binary(FinalMeta)), ok = erl_tar:create(ZrpFile, ["zomp.meta", TgzFile]), ok = file:delete(TgzFile), ok = file:delete("zomp.meta"), ok = log(info, "Wrote archive ~ts", [ZrpFile]), halt(0). -spec remove_binaries(TargetDir) -> ok when TargetDir :: file:filename(). %% @private %% Procedure to delete all .beam and .ez files from a given directory starting at %% TargetDir. Called as part of the pre-packaging sanitization procedure. remove_binaries(TargetDir) -> Beams = filelib:wildcard("**/*.{beam,ez}", TargetDir), case [filename:join(TargetDir, Beam) || Beam <- Beams] of [] -> ok; ToDelete -> ok = log(info, "Removing: ~tp", [ToDelete]), lists:foreach(fun file:delete/1, ToDelete) end. %%% Package submission -spec submit(PackageFile) -> no_return() when PackageFile :: file:filename(). %% @private %% Submit a package to the appropriate "prime" server for the given realm. submit(PackageFile) -> Files = extract_zrp(PackageFile), {ok, PackageData} = file:read_file(PackageFile), {"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files), Meta = binary_to_term(MetaBin), {Realm, Package, Version} = maps:get(package_id, Meta), {ok, Socket} = connect_auth(Realm), ok = send(Socket, {submit, {Realm, Package, Version}}), ok = recv_or_die(Socket), ok = gen_tcp:send(Socket, PackageData), ok = log(info, "Done sending contents of ~tp", [PackageFile]), Outcome = recv_or_die(Socket), log(info, "Response: ~tp", [Outcome]), ok = disconnect(Socket), halt(0). -spec send(Socket, Message) -> ok when Socket :: gen_tcp:socket(), Message :: term(). %% @private %% Wrapper for the procedure necessary to send an internal message over the wire. send(Socket, Message) -> Bin = term_to_binary(Message), gen_tcp:send(Socket, Bin). -spec recv_or_die(Socket) -> Result | no_return() when Socket :: gen_tcp:socket(), Result :: ok | {ok, term()}. recv_or_die(Socket) -> receive {tcp, Socket, Bin} -> case binary_to_term(Bin) of ok -> ok; {ok, Response} -> {ok, Response}; {error, bad_realm} -> ok = log(warning, "No such realm at the connected node."), halt(1); {error, bad_package} -> ok = log(warning, "No such package."), halt(1); {error, bad_version} -> ok = log(warning, "No such version."), halt(1); {error, not_in_queue} -> ok = log(warning, "Version is not queued."), halt(1); {error, bad_message} -> ok = log(error, "Oh noes! zx sent an illegal message!"), halt(1); {error, already_exists} -> ok = log(warning, "Server refuses: already_exists"), halt(1); {error, {system, Reason}} -> Message = "Node experienced system error: ~160tp", ok = log(warning, Message, [{error, Reason}]), halt(1); Unexpected -> ok = log(warning, "Unexpected message: ~tp", [Unexpected]), halt(1) end; {tcp_closed, Socket} -> ok = log(warning, "Lost connection to node unexpectedly."), halt(1) after 5000 -> ok = log(warning, "Node timed out."), halt(1) end. -spec halt_on_unexpected_close() -> no_return(). halt_on_unexpected_close() -> ok = log(warning, "Socket closed unexpectedly."), halt(1). -spec connect_user(realm()) -> gen_tcp:socket() | no_return(). %% @private %% Connect to a given realm, whatever method is required. connect_user(Realm) -> ok = log(info, "Connecting to realm ~ts...", [Realm]), Hosts = case file:consult(hosts_cache_file(Realm)) of {ok, Cached} -> Cached; {error, enoent} -> [] end, connect_user(Realm, Hosts). -spec connect_user(realm(), [host()]) -> gen_tcp:socket() | no_return(). %% @private %% Try to connect to a subordinate host, if there are none then connect to prime. connect_user(Realm, []) -> {Host, Port} = get_prime(Realm), HostString = case io_lib:printable_unicode_list(Host) of true -> Host; false -> inet:ntoa(Host) end, ok = log(info, "Trying prime at ~ts:~160tp", [HostString, Port]), case gen_tcp:connect(Host, Port, connect_options(), 5000) of {ok, Socket} -> confirm_user(Realm, Socket, []); {error, Error} -> ok = log(warning, "Connection problem with prime: ~tp", [Error]), halt(0) end; connect_user(Realm, Hosts = [Node = {Host, Port} | Rest]) -> ok = log(info, "Trying node at ~ts:~tp", [inet:ntoa(Host), Port]), case gen_tcp:connect(Host, Port, connect_options(), 5000) of {ok, Socket} -> confirm_user(Realm, Socket, Hosts); {error, Error} -> ok = log(warning, "Connection problem with ~tp: ~tp", [Node, Error]), connect_user(Realm, Rest) end. -spec confirm_user(Realm, Socket, Hosts) -> Socket | no_return() when Realm :: realm(), Socket :: gen_tcp:socket(), Hosts :: [host()]. %% @private %% Confirm the zomp node can handle "OTPR USER 1" and is accepting connections or try %% another node. confirm_user(Realm, Socket, Hosts) -> {ok, {Addr, Port}} = inet:peername(Socket), Host = inet:ntoa(Addr), ok = gen_tcp:send(Socket, <<"OTPR USER 1">>), receive {tcp, Socket, Bin} -> case binary_to_term(Bin) of ok -> ok = log(info, "Connected to ~ts:~p", [Host, Port]), confirm_serial(Realm, Socket, Hosts); {redirect, Next} -> ok = log(info, "Redirected..."), ok = disconnect(Socket), connect_user(Realm, Next ++ Hosts) end; {tcp_closed, Socket} -> halt_on_unexpected_close() after 5000 -> ok = log(warning, "Host ~ts:~p timed out.", [Host, Port]), ok = disconnect(Socket), connect_user(Realm, Hosts) end. -spec confirm_serial(Realm, Socket, Hosts) -> Socket | no_return() when Realm :: realm(), Socket :: gen_tcp:socket(), Hosts :: [host()]. %% @private %% Confirm that the connected host has a valid serial for the realm zx is trying to %% reach, and if not retry on another node. confirm_serial(Realm, Socket, Hosts) -> SerialFile = filename:join(zomp_dir(), "realm.serials"), Serials = case file:consult(SerialFile) of {ok, Ss} -> Ss; {error, enoent} -> [] end, Serial = case lists:keyfind(Realm, 1, Serials) of false -> 0; {Realm, S} -> S end, ok = send(Socket, {latest, Realm}), receive {tcp, Socket, Bin} -> case binary_to_term(Bin) of {ok, Serial} -> ok = log(info, "Node's serial same as ours."), Socket; {ok, Current} when Current > Serial -> ok = log(info, "Node's serial newer than ours. Storing."), NewSerials = lists:keystore(Realm, 1, Serials, {Realm, Current}), {ok, Host} = inet:peername(Socket), ok = write_terms(hosts_cache_file(Realm), [Host | Hosts]), ok = write_terms(SerialFile, NewSerials), Socket; {ok, Current} when Current < Serial -> log(info, "Our serial: ~tp, node serial: ~tp.", [Serial, Current]), ok = log(info, "Node's serial older than ours. Trying another."), ok = disconnect(Socket), connect_user(Realm, Hosts); {error, bad_realm} -> ok = log(info, "Node is no longer serving realm. Trying another."), ok = disconnect(Socket), connect_user(Realm, Hosts) end; {tcp_closed, Socket} -> halt_on_unexpected_close() after 5000 -> ok = log(info, "Host timed out on confirm_serial. Trying another."), ok = disconnect(Socket), connect_user(Realm, Hosts) end. -spec connect_auth_or_die(realm()) -> gen_tcp:socket() | no_return(). connect_auth_or_die(Realm) -> case connect_auth(Realm) of {ok, Socket} -> Socket; Error -> M1 = "Connection failed to realm prime with ~160tp.", ok = log(warning, M1, [Error]), halt(1) end. -spec connect_auth(Realm) -> Result when Realm :: realm(), Result :: {ok, gen_tcp:socket()} | {error, Reason :: term()}. %% @private %% Connect to one of the servers in the realm constellation. connect_auth(Realm) -> RealmConf = load_realm_conf(Realm), {User, KeyID, Key} = prep_auth(Realm, RealmConf), {prime, {Host, Port}} = lists:keyfind(prime, 1, RealmConf), case gen_tcp:connect(Host, Port, connect_options(), 5000) of {ok, Socket} -> ok = log(info, "Connected to ~tp prime.", [Realm]), connect_auth(Socket, Realm, User, KeyID, Key); Error = {error, E} -> ok = log(warning, "Connection problem: ~tp", [E]), {error, Error} end. -spec connect_auth(Socket, Realm, User, KeyID, Key) -> Result when Socket :: gen_tcp:socket(), Realm :: realm(), User :: user(), KeyID :: key_id(), Key :: term(), Result :: {ok, gen_tcp:socket()} | {error, timeout}. %% @private %% Send a protocol ID string to notify the server what we're up to, disconnect %% if it does not return an "OK" response within 5 seconds. connect_auth(Socket, Realm, User, KeyID, Key) -> ok = gen_tcp:send(Socket, <<"OTPR AUTH 1">>), receive {tcp, Socket, Bin} -> ok = binary_to_term(Bin, [safe]), confirm_auth(Socket, Realm, User, KeyID, Key); {tcp_closed, Socket} -> halt_on_unexpected_close() after 5000 -> ok = log(warning, "Host realm ~160tp prime timed out.", [Realm]), {error, auth_timeout} end. confirm_auth(Socket, Realm, User, KeyID, Key) -> ok = send(Socket, {User, KeyID}), receive {tcp, Socket, Bin} -> case binary_to_term(Bin, [safe]) of {sign, Blob} -> Sig = public_key:sign(Blob, sha512, Key), ok = send(Socket, {signed, Sig}), confirm_auth(Socket); {error, not_prime} -> M1 = "Connected node is not prime for realm ~160tp", ok = log(warning, M1, [Realm]), ok = disconnect(Socket), {error, not_prime}; {error, bad_user} -> M2 = "Bad user record ~160tp", ok = log(warning, M2, [User]), ok = disconnect(Socket), {error, bad_user}; {error, unauthorized_key} -> M3 = "Unauthorized user key ~160tp", ok = log(warning, M3, [KeyID]), ok = disconnect(Socket), {error, unauthorized_key}; {error, Reason} -> Message = "Could not begin auth exchange. Failed with ~160tp", ok = log(warning, Message, [Reason]), ok = disconnect(Socket), {error, Reason} end; {tcp_closed, Socket} -> halt_on_unexpected_close() after 5000 -> ok = log(warning, "Host realm ~tp prime timed out.", [Realm]), {error, auth_timeout} end. confirm_auth(Socket) -> receive {tcp, Socket, Bin} -> case binary_to_term(Bin, [safe]) of ok -> {ok, Socket}; Other -> {error, Other} end; {tcp_closed, Socket} -> halt_on_unexpected_close() after 5000 -> {error, timeout} end. -spec prep_auth(Realm, RealmConf) -> {User, KeyID, Key} | no_return() when Realm :: realm(), RealmConf :: [term()], User :: user(), KeyID :: key_id(), Key :: term(). %% @private %% Loads the appropriate User, KeyID and reads in a registered key for use in %% connect_auth/4. prep_auth(Realm, RealmConf) -> UsersFile = filename:join(zomp_dir(), "zomp.users"), Users = case file:consult(UsersFile) of {ok, U} -> U; {error, enoent} -> ok = log(warning, "You do not have any users configured."), halt(1) end, {User, KeyIDs} = case lists:keyfind(Realm, 1, Users) of {Realm, UserName, []} -> W = "User ~tp does not have any keys registered for realm ~tp.", ok = log(warning, W, [UserName, Realm]), ok = log(info, "Contact the following sysop(s) to register a key:"), {sysops, Sysops} = lists:keyfind(sysops, 1, RealmConf), PrintContact = fun({_, _, Email, Name, _, _}) -> log(info, "Sysop: ~ts Email: ~ts", [Name, Email]) end, ok = lists:foreach(PrintContact, Sysops), halt(1); {Realm, UserName, KeyNames} -> KIDs = [{Realm, KeyName} || KeyName <- KeyNames], {{Realm, UserName}, KIDs}; false -> Message = "You are not a user of the given realm: ~160tp.", ok = log(warning, Message, [Realm]), halt(1) end, KeyID = hd(KeyIDs), true = ensure_keypair(KeyID), {ok, Key} = loadkey(private, KeyID), {User, KeyID, Key}. -spec connect_options() -> [gen_tcp:connect_option()]. %% @private %% Hide away the default options used for TCP connections. connect_options() -> [{packet, 4}, {mode, binary}, {active, true}]. -spec get_prime(realm()) -> host(). %% @private %% Check the given Realm's config file for the current prime node and return it. get_prime(Realm) -> RealmMeta = realm_meta(Realm), {prime, Prime} = lists:keyfind(prime, 1, RealmMeta), Prime. -spec hosts_cache_file(realm()) -> file:filename(). %% @private %% Given a Realm name, construct a realm's .hosts filename and return it. hosts_cache_file(Realm) -> filename:join(zomp_dir(), Realm ++ ".hosts"). -spec disconnect(gen_tcp:socket()) -> ok. %% @private %% Gracefully shut down a socket, logging (but sidestepping) the case when the socket %% has already been closed by the other side. disconnect(Socket) -> case gen_tcp:shutdown(Socket, read_write) of ok -> log(info, "Disconnected from ~tp", [Socket]); {error, Error} -> Message = "Shutdown connection ~p failed with: ~p", log(warning, Message, [Socket, Error]) end. -spec ensure_keypair(key_id()) -> true | no_return(). %% @private %% Check if both the public and private key based on KeyID exists. ensure_keypair(KeyID = {Realm, KeyName}) -> case {have_public_key(KeyID), have_private_key(KeyID)} of {true, true} -> true; {false, true} -> Message = "Public key for ~tp/~tp cannot be found", ok = log(error, Message, [Realm, KeyName]), halt(1); {true, false} -> Message = "Private key for ~tp/~tp cannot be found", ok = log(error, Message, [Realm, KeyName]), halt(1); {false, false} -> Message = "Key pair for ~tp/~tp cannot be found", ok = log(error, Message, [Realm, KeyName]), halt(1) end. -spec have_public_key(key_id()) -> boolean(). %% @private %% Determine whether the public key indicated by KeyID is in the keystore. have_public_key({Realm, KeyName}) -> PublicKeyFile = KeyName ++ ".pub.der", PublicKeyPath = filename:join([zomp_dir(), "key", Realm, PublicKeyFile]), filelib:is_regular(PublicKeyPath). -spec have_private_key(key_id()) -> boolean(). %% @private %% Determine whether the private key indicated by KeyID is in the keystore. have_private_key({Realm, KeyName}) -> PrivateKeyFile = KeyName ++ ".key.der", PrivateKeyPath = filename:join([zomp_dir(), "key", Realm, PrivateKeyFile]), filelib:is_regular(PrivateKeyPath). -spec realm_meta(Realm) -> Meta | no_return() when Realm :: string(), Meta :: [{atom(), term()}]. %% @private %% Given a realm name, try to locate and read the realm's configuration file if it %% exists, exiting with an appropriate error message if there is a problem reading %% the file. realm_meta(Realm) -> RealmFile = filename:join(zomp_dir(), Realm ++ ".realm"), case file:consult(RealmFile) of {ok, Meta} -> Meta; {error, enoent} -> ok = log(error, "No realm file for ~ts", [Realm]), halt(1); Error -> Message = "Open realm file ~ts failed with ~ts", error_exit(Message, [RealmFile, Error], ?FILE, ?LINE) end. %%% Key generation -spec prompt_keygen() -> key_id(). %% @private %% Prompt the user for a valid KeyPrefix to use for naming a new RSA keypair. prompt_keygen() -> Message = "~n Enter a name for your new keys.~n~n" " Valid names must start with a lower-case letter, and can include~n" " only lower-case letters, numbers, and periods, but no series of~n" " consecutive periods. (That is: [a-z0-9\\.])~n~n" " To designate the key as realm-specific, enter the realm name and~n" " key name separated by a space.~n~n" " Example: some.realm my.key~n", ok = io:format(Message), Input = get_input(), {Realm, KeyName} = case string:lexemes(Input, " ") of [R, K] -> {R, K}; [K] -> {"otpr", K} end, case {valid_lower0_9(Realm), valid_label(KeyName)} of {true, true} -> {Realm, KeyName}; {false, true} -> ok = io:format("Bad realm name ~tp. Try again.~n", [Realm]), prompt_keygen(); {true, false} -> ok = io:format("Bad key name ~tp. Try again.~n", [KeyName]), prompt_keygen(); {false, false} -> ok = io:format("NUTS! Both key and realm names are illegal. Try again.~n"), prompt_keygen() end. -spec create_keypair() -> no_return(). %% @private %% Execute the key generation procedure for 16k RSA keys once and then terminate. create_keypair() -> ok = file:set_cwd(zomp_dir()), KeyID = prompt_keygen(), case generate_rsa(KeyID) of {ok, _, _} -> halt(0); Error -> error_exit("create_keypair/0 error: ~tp", [Error], ?FILE, ?LINE) end. -spec generate_rsa(KeyID) -> Result when KeyID :: key_id(), Result :: {ok, KeyFile, PubFile} | {error, keygen_fail}, KeyFile :: file:filename(), PubFile :: file:filename(). %% @private %% Generate an RSA keypair and write them in der format to the current directory, using %% filenames derived from Prefix. %% NOTE: The current version of this command is likely to only work on a unix system. generate_rsa({Realm, KeyName}) -> KeyDir = filename:join([zomp_dir(), "key", Realm]), ok = force_dir(KeyDir), PemFile = filename:join(KeyDir, KeyName ++ ".pub.pem"), KeyFile = filename:join(KeyDir, KeyName ++ ".key.der"), PubFile = filename:join(KeyDir, KeyName ++ ".pub.der"), ok = lists:foreach(fun halt_if_exists/1, [PemFile, KeyFile, PubFile]), ok = log(info, "Generating ~p and ~p. Please be patient...", [KeyFile, PubFile]), ok = gen_p_key(KeyFile), ok = der_to_pem(KeyFile, PemFile), {ok, PemBin} = file:read_file(PemFile), [PemData] = public_key:pem_decode(PemBin), Pub = public_key:pem_entry_decode(PemData), PubDer = public_key:der_encode('RSAPublicKey', Pub), ok = file:write_file(PubFile, PubDer), case check_key(KeyFile, PubFile) of true -> ok = file:delete(PemFile), ok = log(info, "~ts and ~ts agree", [KeyFile, PubFile]), ok = log(info, "Wrote private key to: ~ts.", [KeyFile]), ok = log(info, "Wrote public key to: ~ts.", [PubFile]), {ok, KeyFile, PubFile}; false -> ok = lists:foreach(fun file:delete/1, [PemFile, KeyFile, PubFile]), ok = log(error, "Something has gone wrong."), {error, keygen_fail} end. -spec halt_if_exists(file:filename()) -> ok | no_return(). %% @private %% A helper function to guard against overwriting an existing file. Halts execution if %% the file is found to exist. halt_if_exists(Path) -> case filelib:is_file(Path) of true -> ok = log(error, "~ts already exists! Halting.", [Path]), halt(1); false -> ok end. -spec gen_p_key(KeyFile) -> ok when KeyFile :: file:filename(). %% @private %% Format an openssl shell command that will generate proper 16k RSA keys. gen_p_key(KeyFile) -> Command = io_lib:format("~ts genpkey" " -algorithm rsa" " -out ~ts" " -outform DER" " -pkeyopt rsa_keygen_bits:16384", [openssl(), KeyFile]), Out = os:cmd(Command), io:format(Out). -spec der_to_pem(KeyFile, PemFile) -> ok when KeyFile :: file:filename(), PemFile :: file:filename(). %% @private %% Format an openssl shell command that will convert the given keyfile to a pemfile. %% The reason for this conversion is to sidestep some formatting weirdness that OpenSSL %% injects into its generated DER formatted key output (namely, a few empty headers) %% which Erlang's ASN.1 defintion files do not take into account. A conversion to PEM %% then a conversion back to DER (via Erlang's ASN.1 module) resolves this in a reliable %% way. der_to_pem(KeyFile, PemFile) -> Command = io_lib:format("~ts rsa" " -inform DER" " -in ~ts" " -outform PEM" " -pubout" " -out ~ts", [openssl(), KeyFile, PemFile]), Out = os:cmd(Command), io:format(Out). -spec check_key(KeyFile, PubFile) -> Result when KeyFile :: file:filename(), PubFile :: file:filename(), Result :: true | false. %% @private %% Compare two keys for pairedness. check_key(KeyFile, PubFile) -> {ok, KeyBin} = file:read_file(KeyFile), {ok, PubBin} = file:read_file(PubFile), Key = public_key:der_decode('RSAPrivateKey', KeyBin), Pub = public_key:der_decode('RSAPublicKey', PubBin), TestMessage = <<"Some test data to sign.">>, Signature = public_key:sign(TestMessage, sha512, Key), public_key:verify(TestMessage, sha512, Signature, Pub). -spec openssl() -> Executable | no_return() when Executable :: file:filename(). %% @private %% Attempt to locate the installed openssl executable for use in shell commands. %% Halts execution with an error message if the executable cannot be found. openssl() -> OpenSSL = case os:type() of {unix, _} -> "openssl"; {win32, _} -> "openssl.exe" end, ok = case os:find_executable(OpenSSL) of false -> ok = log(error, "OpenSSL could not be found in this system's PATH."), ok = log(error, "Install OpenSSL and then retry."), error_exit("Missing system dependenct: OpenSSL", ?FILE, ?LINE); Path -> log(info, "OpenSSL executable found at: ~ts", [Path]) end, OpenSSL. -spec loadkey(Type, KeyID) -> Result when Type :: private | public, KeyID :: key_id(), Result :: {ok, DecodedKey :: term()} | {error, Reason :: term()}. %% @private %% Hide the details behind reading and loading DER encoded RSA key files. loadkey(Type, {Realm, KeyName}) -> {DerType, Path} = case Type of private -> P = filename:join([zomp_dir(), "key", Realm, KeyName ++ ".key.der"]), {'RSAPrivateKey', P}; public -> P = filename:join([zomp_dir(), "key", Realm, KeyName ++ ".pub.der"]), {'RSAPublicKey', P} end, ok = log(info, "Loading key from file ~ts", [Path]), case file:read_file(Path) of {ok, Bin} -> {ok, public_key:der_decode(DerType, Bin)}; Error -> Error end. %%% Generate PLT -spec create_plt() -> no_return(). %% @private %% Generate a fresh PLT file that includes most basic core applications needed to %% make a resonable estimate of a type system, write the name of the PLT to stdout, %% and exit. create_plt() -> ok = build_plt(), halt(0). build_plt() -> PLT = default_plt(), Template = "dialyzer --build_plt" " --output_plt ~ts" " --apps asn1 reltool wx common_test crypto erts eunit inets" " kernel mnesia public_key sasl ssh ssl stdlib", Command = io_lib:format(Template, [PLT]), Message = "Generating PLT file and writing to: ~tp~n" " There will be a list of \"unknown functions\" in the final output.~n" " Don't panic. This is normal. Turtles all the way down, after all...", ok = log(info, Message, [PLT]), ok = log(info, "This may take a while. Patience is a virtue."), Out = os:cmd(Command), log(info, Out). default_plt() -> filename:join(zomp_dir(), "basic.plt"). %%% Dialyze -spec dialyze() -> no_return(). %% @private %% Preps a copy of this script for typechecking with Dialyzer. dialyze() -> PLT = default_plt(), ok = case filelib:is_regular(PLT) of true -> log(info, "Using PLT: ~tp", [PLT]); false -> build_plt() end, TmpDir = filename:join(zomp_dir(), "tmp"), Me = escript:script_name(), EvilTwin = filename:join(TmpDir, filename:basename(Me ++ ".erl")), ok = log(info, "Temporarily reconstructing ~tp as ~tp", [Me, EvilTwin]), Sed = io_lib:format("sed 's/^#!.*$//' ~s > ~s", [Me, EvilTwin]), "" = os:cmd(Sed), ok = case dialyzer:run([{init_plt, PLT}, {from, src_code}, {files, [EvilTwin]}]) of [] -> io:format("Dialyzer found no errors and returned no warnings! Yay!~n"); Warnings -> Mine = [dialyzer:format_warning({Tag, {Me, Line}, Msg}) || {Tag, {_, Line}, Msg} <- Warnings], lists:foreach(fun io:format/1, Mine) end, ok = file:delete(EvilTwin), halt(0). %%% Create Realm & Sysop -spec create_user(realm(), username()) -> no_return(). %% @private %% Validate the realm and username provided, prompt the user to either select a keypair %% to use or generate a new one, and bundle a .zuser file for conveyance of the user %% data and his relevant keys (for import into an existing zomp server via `add' %% command like "add packager", "add maintainer" and "add sysop". create_user(Realm, Username) -> Message = "Would be generating a user file for {~160tp, ~160to}.", ok = log(info, Message, [Realm, Username]), halt(0). -spec create_realm() -> no_return(). %% @private %% Prompt the user to input the information necessary to create a new zomp realm, %% package the data appropriately for the server and deliver the final keys and %% realm file to the user. create_realm() -> ConfFile = filename:join(zomp_dir(), "zomp.conf"), case file:consult(ConfFile) of {ok, ZompConf} -> create_realm(ZompConf); {error, enoent} -> create_realm([]) end. -spec create_realm(ZompConf) -> no_return() when ZompConf :: [{Key :: atom(), Value :: term()}]. create_realm(ZompConf) -> Instructions = "~n" " Enter a name for your new realm.~n" " Names can contain only lower-case letters, numbers and the underscore.~n" " Names must begin with a lower-case letter.~n", ok = io:format(Instructions), Realm = get_input(), case valid_lower0_9(Realm) of true -> RealmFile = filename:join(zomp_dir(), Realm ++ ".realm"), case filelib:is_regular(RealmFile) of false -> create_realm(ZompConf, Realm); true -> ok = io:format("That realm already exists. Be more original.~n"), create_realm(ZompConf) end; false -> ok = io:format("Bad realm name \"~ts\". Try again.~n", [Realm]), create_realm(ZompConf) end. -spec create_realm(ZompConf, Realm) -> no_return() when ZompConf :: [{Key :: atom(), Value :: term()}], Realm :: realm(). create_realm(ZompConf, Realm) -> ExAddress = case lists:keyfind(external_address, 1, ZompConf) of false -> prompt_external_address(); {external_address, none} -> prompt_external_address(); {external_address, Current} -> prompt_external_address(Current) end, create_realm(ZompConf, Realm, ExAddress). -spec prompt_external_address() -> Result when Result :: inet:hostname() | inet:ip_address(). prompt_external_address() -> Message = external_address_prompt(), ok = io:format(Message), case get_input() of "" -> ok = io:format("You need to enter an address.~n"), prompt_external_address(); String -> parse_address(String) end. -spec prompt_external_address(Current) -> Result when Current :: inet:hostname() | inet:ip_address(), Result :: inet:hostname() | inet:ip_address(). prompt_external_address(Current) -> XAString = case inet:ntoa(Current) of {error, einval} -> Current; XAS -> XAS end, Message = external_address_prompt() ++ " [The current public address is: ~ts. Press to keep this address.]~n", ok = io:format(Message, [XAString]), case get_input() of "" -> Current; String -> parse_address(String) end. -spec external_address_prompt() -> string(). external_address_prompt() -> "~n" " Enter a static, valid hostname or IPv4 or IPv6 address at which this host " "can be reached from the public internet (or internal network if it will never " "need to be reached from the internet).~n" " DO NOT INCLUDE A PORT NUMBER IN THIS STEP~n". -spec parse_address(string()) -> inet:hostname() | inet:ip_address(). parse_address(String) -> case inet:parse_address(String) of {ok, Address} -> Address; {error, einval} -> String end. -spec create_realm(ZompConf, Realm, ExAddress) -> no_return() when ZompConf :: [{Key :: atom(), Value :: term()}], Realm :: realm(), ExAddress :: inet:hostname() | inet:ip_address(). create_realm(ZompConf, Realm, ExAddress) -> Current = case lists:keyfind(external_port, 1, ZompConf) of false -> 11311; {external_port, none} -> 11311; {external_port, P} -> P end, Message = "~n" " Enter the public (external) port number at which this service should be " "available. (This might be different from the local port number if you are " "forwarding ports or have a complex network layout.)~n", ok = io:format(Message), ExPort = prompt_port_number(Current), create_realm(ZompConf, Realm, ExAddress, ExPort). -spec create_realm(ZompConf, Realm, ExAddress, ExPort) -> no_return() when ZompConf :: [{Key :: atom(), Value :: term()}], Realm :: realm(), ExAddress :: inet:hostname() | inet:ip_address(), ExPort :: inet:port_number(). create_realm(ZompConf, Realm, ExAddress, ExPort) -> Current = case lists:keyfind(internal_port, 1, ZompConf) of false -> 11311; {internal_port, none} -> 11311; {internal_port, P} -> P end, Message = "~n" " Enter the local (internal/LAN) port number at which this service should be " "available. (This might be different from the public port visible from the " "internet if you are port forwarding or have a complex network layout.)~n", ok = io:format(Message), InPort = prompt_port_number(Current), create_realm(ZompConf, Realm, ExAddress, ExPort, InPort). -spec prompt_port_number(Current) -> Result when Current :: inet:port_number(), Result :: inet:port_number(). prompt_port_number(Current) -> Instructions = " A valid port is any number from 1 to 65535." " [Press enter to accept the current setting: ~tw]~n", ok = io:format(Instructions, [Current]), case get_input() of "" -> Current; S -> try case list_to_integer(S) of Port when 16#ffff >= Port, Port > 0 -> Port; Illegal -> Whoops = "Whoops! ~tw is out of bounds (1~65535). Try again.~n", ok = io:format(Whoops, [Illegal]), prompt_port_number(Current) end catch error:badarg -> ok = io:format("~tp is not a port number. Try again...", [S]), prompt_port_number(Current) end end. -spec create_realm(ZompConf, Realm, ExAddress, ExPort, InPort) -> no_return() when ZompConf :: [{Key :: atom(), Value :: term()}], Realm :: realm(), ExAddress :: inet:hostname() | inet:ip_address(), ExPort :: inet:port_number(), InPort :: inet:port_number(). create_realm(ZompConf, Realm, ExAddress, ExPort, InPort) -> Instructions = "~n" " Enter a username for the realm sysop.~n" " Names can contain only lower-case letters, numbers and the underscore.~n" " Names must begin with a lower-case letter.~n", ok = io:format(Instructions), UserName = get_input(), case valid_lower0_9(UserName) of true -> create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName); false -> ok = io:format("Bad username ~tp. Try again.~n", [UserName]), create_realm(ZompConf, Realm, ExAddress, ExPort, InPort) end. -spec create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName) -> no_return() when ZompConf :: [{Key :: atom(), Value :: term()}], Realm :: realm(), ExAddress :: inet:hostname() | inet:ip_address(), ExPort :: inet:port_number(), InPort :: inet:port_number(), UserName :: string(). create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName) -> Instructions = "~n" " Enter an email address for the realm sysop.~n" " Valid email address rules apply though the checking done here is quite " "minimal. Check the address you enter carefully. The only people who will " "suffer from an invalid address are your users.~n", ok = io:format(Instructions), Email = get_input(), [User, Host] = string:lexemes(Email, "@"), case {valid_lower0_9(User), valid_label(Host)} of {true, true} -> create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName, Email); {false, true} -> Message = "The user part of the email address seems invalid. Try again.~n", ok = io:format(Message), create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName); {true, false} -> Message = "The host part of the email address seems invalid. Try again.~n", ok = io:format(Message), create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName); {false, false} -> Message = "This email address seems like its totally bonkers. Try again.~n", ok = io:format(Message), create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName) end. -spec create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName, Email) -> no_return() when ZompConf :: [{Key :: atom(), Value :: term()}], Realm :: realm(), ExAddress :: inet:hostname() | inet:ip_address(), ExPort :: inet:port_number(), InPort :: inet:port_number(), UserName :: string(), Email :: string(). create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName, Email) -> Instructions = "~n" " Enter the real name (or whatever name people recognize) for the sysop.~n" " There are no rules for this one. Any valid UTF-8 printables are legal.~n", ok = io:format(Instructions), RealName = get_input(), create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName, Email, RealName). -spec create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName, Email, RealName) -> no_return() when ZompConf :: [{Key :: atom(), Value :: term()}], Realm :: realm(), ExAddress :: inet:hostname() | inet:ip_address(), ExPort :: inet:port_number(), InPort :: inet:port_number(), UserName :: string(), Email :: string(), RealName :: string(). create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName, Email, RealName) -> ok = io:format("~nGenerating keys. This might take a while, so settle in...~n"), {ok, RealmKey, RealmPub} = generate_rsa({Realm, Realm ++ ".1.realm"}), {ok, PackageKey, PackagePub} = generate_rsa({Realm, Realm ++ ".1.package"}), {ok, SysopKey, SysopPub} = generate_rsa({Realm, UserName ++ ".1"}), ok = log(info, "Generated 16k RSA pair ~ts ~ts", [RealmKey, RealmPub]), ok = log(info, "Generated 16k RSA pair ~ts ~ts", [PackageKey, PackagePub]), ok = log(info, "Generated 16k RSA pair ~ts ~ts", [SysopKey, SysopPub]), Timestamp = calendar:now_to_universal_time(erlang:timestamp()), {ok, RealmPubData} = file:read_file(RealmPub), RealmPubRecord = {{Realm, filename:basename(RealmPub, ".pub.der")}, realm, {realm, Realm}, crypto:hash(sha512, RealmPubData), Timestamp}, {ok, PackagePubData} = file:read_file(PackagePub), PackagePubRecord = {{Realm, filename:basename(PackagePub, ".pub.der")}, package, {realm, Realm}, crypto:hash(sha512, PackagePubData), Timestamp}, UserRecord = {{Realm, UserName}, [filename:basename(SysopPub, ".pub.der")], Email, RealName}, RealmSettings = [{realm, Realm}, {revision, 0}, {prime, {ExAddress, ExPort}}, {private, []}, {mirrors, []}, {sysops, [UserRecord]}, {realm_keys, [RealmPubRecord]}, {package_keys, [PackagePubRecord]}], ZompSettings = [{managed, [Realm]}, {external_address, ExAddress}, {external_port, ExPort}, {internal_port, InPort}], RealmFN = Realm ++ ".realm", RealmConf = filename:join(zomp_dir(), RealmFN), ok = write_terms(RealmConf, RealmSettings), {ok, CWD} = file:get_cwd(), {ok, TempDir} = mktemp_dir("zomp"), ok = file:set_cwd(TempDir), KeyDir = filename:join("key", Realm), ok = filelib:ensure_dir(KeyDir), ok = file:make_dir(KeyDir), KeyCopy = fun(K) -> {ok, _} = file:copy(K, filename:join(KeyDir, filename:basename(K))), ok end, TarOpts = [compressed, {cwd, TempDir}], ok = write_terms(RealmFN, RealmSettings), ok = KeyCopy(PackagePub), ok = KeyCopy(RealmPub), PublicZRF = filename:join(CWD, Realm ++ ".zrf"), Files = filelib:wildcard("**"), ok = erl_tar:create(PublicZRF, [RealmFN, "key"], TarOpts), ok = KeyCopy(SysopPub), ok = write_terms("zomp.conf", ZompSettings), PrimeZRF = filename:join(CWD, Realm ++ ".zpf"), ok = erl_tar:create(PrimeZRF, [RealmFN, "zomp.conf", "key"], TarOpts), ok = file:set_cwd(zomp_dir()), KeyBundle = filename:join(CWD, Realm ++ ".zkf"), ok = erl_tar:create(KeyBundle, [KeyDir], [compressed]), ok = file:set_cwd(CWD), ok = rm_rf(TempDir), Message = "============================================================================~n" "DONE!~n" "~n" "The realm ~ts has been created and is accessible from the current system.~n" "Three configuration bundles have been created in the current directory:~n" "~n" " 1. ~ts ~n" "This is the PRIVATE realm file you will need to install on the realm's prime~n" "node. It includes the your (the sysop's) public key.~n" "~n" " 2. ~ts ~n" "This file is the PUBLIC realm file other zomp nodes and zx users will need to~n" "access the realm. It does not include your (the sysop's) public key.~n" "~n" " 3. ~ts ~n" "This is the bundle of ALL KEYS that are defined in this realm at the moment.~n" "~n" "Now you need to make copies of these three files and back them up.~n" "~n" "On the PRIME NODE you need to run `zx add realm ~ts` and follow the prompts~n" "to cause it to begin serving that realm as prime. (Node restart required.)~n" "~n" "On all zx CLIENTS that want to access your new realm and on all subordinate~n" "MIRROR NODES the command `zx add realm ~ts` will need to be run.~n" "The method of public realm file distribution (~ts) is up to you.~n" "~n" "~n" "Public & Private key installation (if you need to recover them or perform~n" "sysop functions from another computer) is `zx add keybundle ~ts`.~n" "============================================================================~n", Substitutions = [Realm, PrimeZRF, PublicZRF, KeyBundle, PrimeZRF, PublicZRF, PublicZRF, KeyBundle], ok = io:format(Message, Substitutions), halt(0). -spec create_realmfile(realm()) -> no_return(). create_realmfile(Realm) -> RealmConf = load_realm_conf(Realm), ok = log(info, "Realm found, creating realm file..."), {revision, Revision} = lists:keyfind(revision, 1, RealmConf), {realm_keys, RealmKeys} = lists:keyfind(realm_keys, 1, RealmConf), {package_keys, PackageKeys} = lists:keyfind(package_keys, 1, RealmConf), RealmKeyIDs = [element(1, K) || K <- RealmKeys], PackageKeyIDs = [element(1, K) || K <- PackageKeys], create_realmfile(Realm, Revision, RealmKeyIDs, PackageKeyIDs). -spec create_realmfile(Realm, Revision, RealmKeyIDs, PackageKeyIDs) -> ok when Realm :: realm(), Revision :: non_neg_integer(), RealmKeyIDs :: [key_id()], PackageKeyIDs :: [key_id()]. create_realmfile(Realm, Revision, RealmKeyIDs, PackageKeyIDs) -> {ok, CWD} = file:get_cwd(), ok = file:set_cwd(zomp_dir()), KeyPath = fun({R, K}) -> filename:join(["key", R, K ++ ".pub.der"]) end, RealmKeyPaths = lists:map(KeyPath, RealmKeyIDs), PackageKeyPaths = lists:map(KeyPath, PackageKeyIDs), Targets = [realm_conf(Realm) | RealmKeyPaths ++ PackageKeyPaths], OutFile = filename:join(CWD, Realm ++ "." ++ integer_to_list(Revision) ++ ".zrf"), ok = erl_tar:create(OutFile, Targets, [compressed]), ok = log(info, "Realm conf file written to ~ts", [OutFile]), halt(0). -spec create_sysop() -> no_return(). create_sysop() -> ok = log(info, "Fo' realz, yo! We be sysoppin up in hurr!"), halt(0). %%% Network operations and package utilities -spec install(package_id()) -> ok. %% @private %% Install a package from the cache into the local system. install(PackageID) -> PackageString = package_string(PackageID), ok = log(info, "Installing ~ts", [PackageString]), ZrpFile = filename:join("zrp", namify_zrp(PackageID)), Files = extract_zrp(ZrpFile), TgzFile = namify_tgz(PackageID), {TgzFile, TgzData} = lists:keyfind(TgzFile, 1, Files), {"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files), Meta = binary_to_term(MetaBin), {KeyID, Signature} = maps:get(sig, Meta), {ok, PubKey} = loadkey(public, KeyID), ok = ensure_package_dirs(PackageID), PackageDir = filename:join("lib", PackageString), ok = force_dir(PackageDir), ok = verify(TgzData, Signature, PubKey), ok = erl_tar:extract({binary, TgzData}, [compressed, {cwd, PackageDir}]), log(info, "~ts installed", [PackageString]). -spec extract_zrp(FileName) -> Files | no_return() when FileName :: file:filename(), Files :: [{file:filename(), binary()}]. %% @private %% Extract a zrp archive, if possible. If not possible, halt execution with as accurate %% an error message as can be managed. extract_zrp(FileName) -> case erl_tar:extract(FileName, [memory]) of {ok, Files} -> Files; {error, {FileName, enoent}} -> Message = "Can't find file ~ts.", error_exit(Message, [FileName], ?FILE, ?LINE); {error, invalid_tar_checksum} -> Message = "~ts is not a valid zrp archive.", error_exit(Message, [FileName], ?FILE, ?LINE); {error, Reason} -> Message = "Extracting package file failed with: ~160tp.", error_exit(Message, [Reason], ?FILE, ?LINE) end. -spec verify(Data, Signature, PubKey) -> ok | no_return() when Data :: binary(), Signature :: binary(), PubKey :: public_key:rsa_public_key(). %% @private %% Verify the RSA Signature of some Data against the given PubKey or halt execution. %% This function always assumes sha512 is the algorithm being used. verify(Data, Signature, PubKey) -> case public_key:verify(Data, sha512, Signature, PubKey) of true -> ok; false -> error_exit("Bad package signature!", ?FILE, ?LINE) end. -spec fetch(Socket, PackageID) -> Result when Socket :: gen_tcp:socket(), PackageID :: package_id(), Result :: ok. %% @private %% Download a package to the local cache. fetch(Socket, PackageID) -> {ok, LatestID} = request_zrp(Socket, PackageID), ok = receive_zrp(Socket, LatestID), log(info, "Fetched ~ts", [package_string(LatestID)]). -spec request_zrp(Socket, PackageID) -> Result when Socket :: gen_tcp:socket(), PackageID :: package_id(), Result :: {ok, Latest :: package_id()} | {error, Reason :: timeout | term()}. request_zrp(Socket, PackageID) -> ok = send(Socket, {fetch, PackageID}), receive {tcp, Socket, Bin} -> case binary_to_term(Bin) of {sending, LatestID} -> {ok, LatestID}; Error = {error, Reason} -> PackageString = package_string(PackageID), Message = "Error receiving package ~ts: ~tp", ok = log(info, Message, [PackageString, Reason]), Error end; {tcp_closed, Socket} -> halt_on_unexpected_close() after 60000 -> {error, timeout} end. -spec receive_zrp(Socket, PackageID) -> Result when Socket :: gen_tcp:socket(), PackageID :: package_id(), Result :: ok | {error, timeout}. receive_zrp(Socket, PackageID) -> receive {tcp, Socket, Bin} -> ZrpPath = filename:join("zrp", namify_zrp(PackageID)), ok = file:write_file(ZrpPath, Bin), ok = send(Socket, ok), log(info, "Wrote ~ts", [ZrpPath]); {tcp_closed, Socket} -> halt_on_unexpected_close() after 60000 -> ok = log(error, "Timeout in socket receive for ~tp", [PackageID]), {error, timeout} end. -spec mktemp_dir(Prefix) -> Result when Prefix :: string(), Result :: {ok, TempDir :: file:filename()} | {error, Reason :: file:posix()}. mktemp_dir(Prefix) -> Rand = integer_to_list(binary:decode_unsigned(crypto:strong_rand_bytes(8)), 36), TempPath = filename:basedir(user_cache, Prefix), TempDir = filename:join(TempPath, Rand), Result1 = filelib:ensure_dir(TempDir), Result2 = file:make_dir(TempDir), case {Result1, Result2} of {ok, ok} -> {ok, TempDir}; {ok, Error} -> Error; {Error, _} -> Error end. %%% Utility functions -spec read_meta() -> package_meta() | no_return(). %% @private %% @equiv read_meta(".") read_meta() -> read_meta("."). -spec read_meta(Dir) -> package_meta() | no_return() when Dir :: file:filename(). %% @private %% Read the `zomp.meta' file from the indicated directory, if possible. If not possible %% then halt execution with an appropriate error message. read_meta(Dir) -> Path = filename:join(Dir, "zomp.meta"), case file:consult(Path) of {ok, Meta} -> maps:from_list(Meta); Error -> ok = log(error, "Failed to open \"zomp.meta\" with ~tp", [Error]), ok = log(error, "Wrong directory?"), halt(1) end. -spec write_meta(package_meta()) -> ok. %% @private %% @equiv write_meta(".") write_meta(Meta) -> write_meta(".", Meta). -spec write_meta(Dir, Meta) -> ok when Dir :: file:filename(), Meta :: package_meta(). %% @private %% Write the contents of the provided meta structure (a map these days) as a list of %% Erlang K/V terms. write_meta(Dir, Meta) -> Path = filename:join(Dir, "zomp.meta"), ok = write_terms(Path, maps:to_list(Meta)). -spec write_terms(Filename, Terms) -> ok when Filename :: file:filename(), Terms :: [term()]. %% @private %% Provides functionality roughly inverse to file:consult/1. write_terms(Filename, List) -> Format = fun(Term) -> io_lib:format("~tp.~n", [Term]) end, Text = lists:map(Format, List), file:write_file(Filename, Text). -spec build(package_id()) -> ok. %% @private %% Given an AppID, build the project from source and add it to the current lib path. build(PackageID) -> {ok, CWD} = file:get_cwd(), ok = file:set_cwd(package_home(PackageID)), ok = build(), file:set_cwd(CWD). -spec build() -> ok. %% @private %% Run any local `zxmake' script needed by the project for non-Erlang code (if present), %% then add the local `ebin/' directory to the runtime search path, and finally build %% the Erlang part of the project with make:all/0 according to the local `Emakefile'. build() -> ZxMake = "zxmake", ok = case filelib:is_regular(ZxMake) of true -> Out = os:cmd(ZxMake), log(info, Out); false -> ok end, true = code:add_patha(filename:absname("ebin")), up_to_date = make:all(), ok. -spec scrub(Deps) -> Scrubbed when Deps :: [package_id()], Scrubbed :: [package_id()]. %% @private %% Take a list of dependencies and return a list of dependencies that are not yet %% installed on the system. scrub([]) -> []; scrub(Deps) -> lists:filter(fun(PackageID) -> not installed(PackageID) end, Deps). -spec installed(package_id()) -> boolean(). %% @private %% True to its name, returns `true' if the package is installed (its directory found), %% `false' otherwise. installed(PackageID) -> PackageString = package_string(PackageID), PackageDir = filename:join("lib", PackageString), filelib:is_dir(PackageDir). -spec rm_rf(file:filename()) -> ok | {error, file:posix()}. %% @private %% Recursively remove files and directories, equivalent to `rm -rf' on unix. rm_rf(Path) -> case filelib:is_dir(Path) of true -> Pattern = filename:join(Path, "**"), Contents = lists:reverse(lists:sort(filelib:wildcard(Pattern))), ok = lists:foreach(fun rm/1, Contents), file:del_dir(Path); false -> file:delete(Path) end. -spec rm(file:filename()) -> ok | {error, file:posix()}. %% @private %% An omnibus delete helper. rm(Path) -> case filelib:is_dir(Path) of true -> file:del_dir(Path); false -> file:delete(Path) end. %%% Input argument mangling -spec valid_lower0_9(string()) -> boolean(). %% @private %% Check whether a provided string is a valid lower0_9. valid_lower0_9([Char | Rest]) when $a =< Char, Char =< $z -> valid_lower0_9(Rest, Char); valid_lower0_9(_) -> false. -spec valid_lower0_9(String, Last) -> boolean() when String :: string(), Last :: char(). valid_lower0_9([$_ | _], $_) -> false; valid_lower0_9([Char | Rest], _) when $a =< Char, Char =< $z; $0 =< Char, Char =< $9; Char == $_ -> valid_lower0_9(Rest, Char); valid_lower0_9([], _) -> true; valid_lower0_9(_, _) -> false. -spec valid_label(string()) -> boolean(). %% @private %% Check whether a provided string is a valid label. valid_label([Char | Rest]) when $a =< Char, Char =< $z -> valid_label(Rest, Char); valid_label(_) -> false. -spec valid_label(String, Last) -> boolean() when String :: string(), Last :: char(). valid_label([$. | _], $.) -> false; valid_label([$_ | _], $_) -> false; valid_label([$- | _], $-) -> false; valid_label([Char | Rest], _) when $a =< Char, Char =< $z; $0 =< Char, Char =< $9; Char == $_; Char == $-; Char == $. -> valid_label(Rest, Char); valid_label([], _) -> true; valid_label(_, _) -> false. -spec string_to_version(string()) -> version(). %% @private %% @equiv string_to_version(string(), "", {z, z, z}) string_to_version(String) -> string_to_version(String, "", {z, z, z}). -spec string_to_version(String, Acc, Version) -> Result when String :: string(), Acc :: list(), Version :: version(), Result :: version(). %% @private %% Accepts a full or partial version string of the form `X.Y.Z', `X.Y' or `X' and %% returns a zomp-type version tuple or crashes on bad data. string_to_version([Char | Rest], Acc, Version) when $0 =< Char andalso Char =< $9 -> string_to_version(Rest, [Char | Acc], Version); string_to_version([$. | Rest], Acc, {z, z, z}) -> X = list_to_integer(lists:reverse(Acc)), string_to_version(Rest, "", {X, z, z}); string_to_version([$. | Rest], Acc, {X, z, z}) -> Y = list_to_integer(lists:reverse(Acc)), string_to_version(Rest, "", {X, Y, z}); string_to_version("", "", Version) -> Version; string_to_version([], Acc, {z, z, z}) -> X = list_to_integer(lists:reverse(Acc)), {X, z, z}; string_to_version([], Acc, {X, z, z}) -> Y = list_to_integer(lists:reverse(Acc)), {X, Y, z}; string_to_version([], Acc, {X, Y, z}) -> Z = list_to_integer(lists:reverse(Acc)), {X, Y, Z}. -spec version_to_string(version()) -> string(). %% @private %% Inverse of string_to_version/3. version_to_string({z, z, z}) -> ""; version_to_string({X, z, z}) -> integer_to_list(X); version_to_string({X, Y, z}) -> lists:flatten(lists:join($., [integer_to_list(Element) || Element <- [X, Y]])); version_to_string({X, Y, Z}) -> lists:flatten(lists:join($., [integer_to_list(Element) || Element <- [X, Y, Z]])). -spec package_id(string()) -> package_id(). %% @private %% Converts a proper package_string to a package_id(). %% This function takes into account missing version elements. %% Examples: %% `{"foo", "bar", {1, 2, 3}} = package_id("foo-bar-1.2.3")' %% `{"foo", "bar", {1, 2, z}} = package_id("foo-bar-1.2")' %% `{"foo", "bar", {1, z, z}} = package_id("foo-bar-1")' %% `{"foo", "bar", {z, z, z}} = package_id("foo-bar")' package_id(String) -> case string:lexemes(String, [$-]) of [Realm, Name, VersionString] -> true = valid_lower0_9(Realm), true = valid_lower0_9(Name), Version = string_to_version(VersionString), {Realm, Name, Version}; [A, B] -> true = valid_lower0_9(A), case valid_lower0_9(B) of true -> {A, B, {z, z, z}}; false -> {"otpr", A, string_to_version(B)} end; [Name] -> true = valid_lower0_9(Name), {"otpr", Name, {z, z, z}} end. -spec package_string(package_id()) -> string(). %% @private %% Map an PackageID to a correct string representation. %% This function takes into account missing version elements. %% Examples: %% `"foo-bar-1.2.3" = package_string({"foo", "bar", {1, 2, 3}})' %% `"foo-bar-1.2" = package_string({"foo", "bar", {1, 2, z}})' %% `"foo-bar-1" = package_string({"foo", "bar", {1, z, z}})' %% `"foo-bar" = package_string({"foo", "bar", {z, z, z}})' package_string({Realm, Name, {z, z, z}}) -> lists:flatten(lists:join($-, [Realm, Name])); package_string({Realm, Name, Version}) -> VersionString = version_to_string(Version), lists:flatten(lists:join($-, [Realm, Name, VersionString])). -spec namify_zrp(PackageID) -> ZrpFileName when PackageID :: package_id(), ZrpFileName :: file:filename(). %% @private %% Map an PackageID to its correct .zrp package file name. namify_zrp(PackageID) -> namify(PackageID, "zrp"). -spec namify_tgz(PackageID) -> TgzFileName when PackageID :: package_id(), TgzFileName :: file:filename(). %% @private %% Map an PackageID to its correct gzipped tarball source bundle filename. namify_tgz(PackageID) -> namify(PackageID, "tgz"). -spec namify(PackageID, Suffix) -> FileName when PackageID :: package_id(), Suffix :: string(), FileName :: file:filename(). %% @private %% Converts an PackageID to a canonical string, then appends the provided %% filename Suffix. namify(PackageID, Suffix) -> PackageString = package_string(PackageID), PackageString ++ "." ++ Suffix. %%% User menu interface (terminal) -spec get_input() -> string(). %% @private %% Provide a standard input prompt and newline sanitized return value. get_input() -> string:trim(io:get_line("(^C to quit): ")). -spec select(Options) -> Selected when Options :: [option()], Selected :: term(). %% @private %% Take a list of Options to present the user, then return the indicated option to the %% caller once the user selects something. select(Options) -> Max = show(Options), case pick(string:to_integer(io:get_line("(or ^C to quit)~n ? ")), Max) of error -> ok = hurr(), select(Options); I -> {_, Value} = lists:nth(I, Options), Value end. -spec select_string(Strings) -> Selected when Strings :: [string()], Selected :: string(). %% @private %% @equiv select([{S, S} || S <- Strings]) select_string(Strings) -> select([{S, S} || S <- Strings]). -spec show(Options) -> Index when Options :: [option()], Index :: pos_integer(). %% @private %% @equiv show(Options, 0). show(Options) -> show(Options, 0). -spec show(Options, Index) -> Count when Options :: [option()], Index :: non_neg_integer(), Count :: pos_integer(). %% @private %% Display the list of options needed to the user, and return the option total count. show([], I) -> I; show([{Label, _} | Rest], I) -> Z = I + 1, ok = io:format(" ~2w - ~ts~n", [Z, Label]), show(Rest, Z). -spec pick({Selection, term()}, Max) -> Result when Selection :: error | integer(), Max :: pos_integer(), Result :: pos_integer() | error. %% @private %% Interpret a user's selection returning either a valid selection index or `error'. pick({error, _}, _) -> error; pick({I, _}, Max) when 0 < I, I =< Max -> I; pick(_, _) -> error. -spec hurr() -> ok. %% @private %% Present an appropriate response when the user derps on selection. hurr() -> io:format("That isn't an option.~n"). %%% Directory & File Management -spec ensure_zomp_home() -> ok. %% @private %% Ensure the zomp home directory exists and is populated. %% Every entry function should run this initially. ensure_zomp_home() -> ZompDir = zomp_dir(), case filelib:is_dir(ZompDir) of true -> ok; false -> setup(ZompDir) end. -spec setup(ZompDir :: file:filename()) -> ok. setup(ZompDir) -> {ok, CWD} = file:get_cwd(), ok = force_dir(ZompDir), ok = file:set_cwd(ZompDir), SubDirs = ["tmp", "key", "var", "lib", "zrp", "etc"], ok = lists:foreach(fun file:make_dir/1, SubDirs), ok = setup_otpr(), ok = log(info, "Zomp userland directory initialized."), file:set_cwd(CWD). -spec setup_otpr() -> ok. setup_otpr() -> log(info, "Here should pull otpr.0.zrf and install it..."). -spec zomp_dir() -> file:filename(). %% @private %% Check the host OS and return the absolute path to the zomp filesystem root. zomp_dir() -> case os:type() of {unix, _} -> Home = os:getenv("HOME"), Dir = ".zomp", filename:join(Home, Dir); {win32, _} -> Drive = os:getenv("HOMEDRIVE"), Path = os:getenv("HOMEPATH"), Dir = "zomp", filename:join([Drive, Path, Dir]) end. -spec ensure_package_dirs(package_id()) -> ok. %% @private %% Procedure to guarantee that directory locations necessary for the indicated app to %% run have been created or halt execution. ensure_package_dirs(PackageID) -> PackageHome = package_home(PackageID), PackageData = package_dir("var", PackageID), PackageConf = package_dir("etc", PackageID), Dirs = [PackageHome, PackageData, PackageConf], ok = lists:foreach(fun force_dir/1, Dirs), log(info, "Created dirs:~n\t~ts~n\t~ts~n\t~ts", Dirs). -spec package_home(PackageID) -> PackageHome when PackageID :: package_id(), PackageHome :: file:filename(). %% @private %% Accept an PackageID and return the installation directory for the indicated %% application. %% NOTE: %% This system does NOT anticipate symlinks of incomplete versions to their latest %% installed version (for example, an incomplete `{1, 2, z}' resolving to a symlink %% `lib/foo-bar-1.2' which is always updated to point to the latest version 1.2.x). package_home(PackageID) -> filename:join([zomp_dir(), "lib", package_string(PackageID)]). -spec package_dir(Prefix, PackageID) -> PackageDataDir when Prefix :: string(), PackageID :: package_id(), PackageDataDir :: file:filename(). %% @private %% Create an absolute path to an application directory prefixed by the inclued argument. package_dir(Prefix, {Realm, Name, _}) -> PackageName = Realm ++ "-" ++ Name, filename:join([zomp_dir(), Prefix, PackageName]). -spec force_dir(Path) -> Result when Path :: file:filename(), Result :: ok | {error, file:posix()}. %% @private %% Guarantee a directory path is created if it is possible to create or if it already %% exists. force_dir(Path) -> case filelib:is_dir(Path) of true -> ok; false -> filelib:ensure_dir(filename:join(Path, "foo")) end. -spec realm_conf(Realm) -> RealmFileName when Realm :: string(), RealmFileName :: file:filename(). %% @private %% Take a realm name, and return the name of the realm filename that would result. realm_conf(Realm) -> Realm ++ ".realm". -spec load_realm_conf(Realm) -> RealmConf | no_return() when Realm :: realm(), RealmConf :: list(). %% @private %% Load the config for the given realm or halt with an error. load_realm_conf(Realm) -> Path = filename:join(zomp_dir(), realm_conf(Realm)), case file:consult(Path) of {ok, C} -> C; {error, enoent} -> ok = log(warning, "Realm ~tp is not configured.", [Realm]), halt(1) end. %%% Usage -spec usage_exit(Code) -> no_return() when Code :: integer(). %% @private %% A convenience function that will display the zx usage message before halting %% with the provided exit code. usage_exit(Code) -> ok = usage(), halt(Code). -spec usage() -> ok. %% @private %% Display the zx command line usage message. usage() -> T = "~n" "zx~n" "~n" "Usage: zx [command] [object] [args]~n" "~n" "Examples:~n" " zx help~n" " zx run PackageID [Args]~n" " zx init Type PackageID~n" " zx install PackageID~n" " zx set dep PackageID~n" " zx set version Version~n" " zx list realms~n" " zx list packages Realm~n" " zx list versions PackageName~n" " zx list pending PackageName~n" " zx list resigns Realm~n" " zx add realm RealmFile~n" " zx add package PackageName~n" " zx add packager PackageName~n" " zx add maintainer PackageName~n" " zx review PackageID~n" " zx approve PackageID~n" " zx reject PackageID~n" " zx resign PackageID~n" " zx drop dep PackageID~n" " zx drop key Realm KeyName~n" " zx drop realm Realm~n" " zx verup Level~n" " zx runlocal [Args]~n" " zx package [Path]~n" " zx submit Path~n" " zx create user Realm Username~n" " zx create keypair~n" " zx create plt~n" " zx create realm~n" " zx create realmfile Realm~n" " zx create sysop~n" "~n" "Where~n" " PackageID :: A string of the form Realm-Name[-Version]~n" " Args :: Arguments to pass to the application~n" " Type :: The project type: a standalone \"app\" or a \"lib\"~n" " Version :: Version string X, X.Y, or X.Y.Z: \"1\", \"1.2\", \"1.2.3\"~n" " RealmFile :: Path to a valid .zrf realm file~n" " Realm :: The name of a realm as a string [:a-z:]~n" " KeyName :: The prefix of a keypair to drop~n" " Level :: The version level, one of \"major\", \"minor\", or \"patch\"~n" " Path :: Path to a valid project directory or .zrp file~n" "~n", io:format(T). %%% Error exits -spec error_exit(Error, Path, Line) -> no_return() when Error :: term(), Path :: file:filename(), Line :: non_neg_integer(). %% @private %% Format an error message in a way that makes it easy to locate. error_exit(Error, Path, Line) -> File = filename:basename(Path), ok = log(error, "~ts:~tp: ~tp", [File, Line, Error]), halt(1). -spec error_exit(Format, Args, Path, Line) -> no_return() when Format :: string(), Args :: [term()], Path :: file:filename(), Line :: non_neg_integer(). %% @private %% Format an error message in a way that makes it easy to locate. error_exit(Format, Args, Path, Line) -> File = filename:basename(Path), ok = log(error, "~ts:~tp: " ++ Format, [File, Line | Args]), halt(1). %%% Logger -spec log(Level, Format) -> ok when Level :: info | warning | error, Format :: string(). %% @private %% @equiv log(Level, Format, []) log(Level, Format) -> log(Level, Format, []). -spec log(Level, Format, Args) -> ok when Level :: info | warning | error, Format :: string(), Args :: [term()]. %% @private %% A logging abstraction to hide whatever logging back end is actually in use. %% Format must adhere to Erlang format string rules, and the arity of Args must match %% the provided format. log(Level, Format, Args) -> Tag = case Level of info -> "[INFO]"; warning -> "[WARNING]"; error -> "[ERROR]" end, io:format("~s ~p: " ++ Format ++ "~n", [Tag, self() | Args]).