%%% @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). -behavior(application). -author("Craig Everett "). -copyright("Craig Everett "). -license("GPL-3.0"). -export([do/1]). -export([subscribe/1, unsubscribe/0]). -export([start/0, stop/0]). -export([start/2, stop/1]). -export_type([serial/0, package_id/0, package/0, realm/0, name/0, version/0, identifier/0, option/0, host/0, key_id/0, key_name/0, key_data/0, user/0, username/0, lower0_9/0, label/0, package_meta/0]). -include("zx_logger.hrl"). %%% Type Definitions -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 identifier() :: realm() | package() | package_id(). -type option() :: {string(), term()}. -type host() :: {string() | inet:ip_address(), inet:port_number()}. -type key_id() :: {realm(), key_name()}. -type key_name() :: lower0_9(). -type key_data() :: {ID :: key_id(), Public :: binary() | <<>>, Private :: binary() | <<>>}. -type user_id() :: {realm(), user_name()}. -type user_name() :: label(). -type contact_info() :: {Type :: string(), Data :: string()}. -type user_data() :: {ID :: user_id(), RealName :: string(), Contact :: contact_info(), KeyData :: [key_data()]}. -type lower0_9() :: [$a..$z | $0..$9 | $_]. -type label() :: [$a..$z | $0..$9 | $_ | $- | $.]. -type package_meta() :: #{package_id := package_id(), deps := [package_id()], type := app | lib}. %%% Command Dispatch -spec do(Args) -> no_return() when Args :: [string()]. %% Dispatch work functions based on the nature of the input arguments. do(["help"]) -> usage_exit(0); do(["run", PackageString | Args]) -> run(PackageString, Args); do(["runlocal" | ArgV]) -> run_local(ArgV); do(["init", "app", PackageString]) -> initialize(app, PackageString); do(["init", "lib", PackageString]) -> initialize(lib, PackageString); do(["install", PackageFile]) -> assimilate(PackageFile); do(["set", "dep", PackageString]) -> set_dep(PackageString); do(["set", "version", VersionString]) -> set_version(VersionString); do(["list", "realms"]) -> list_realms(); do(["list", "packages", Realm]) -> list_packages(Realm); do(["list", "versions", PackageName]) -> list_versions(PackageName); do(["list", "pending", PackageName]) -> list_pending(PackageName); do(["list", "resigns", Realm]) -> list_resigns(Realm); do(["add", "realm", RealmFile]) -> add_realm(RealmFile); do(["add", "package", PackageName]) -> add_package(PackageName); do(["add", "packager", Package, UserName]) -> add_packager(Package, UserName); do(["add", "maintainer", Package, UserName]) -> add_maintainer(Package, UserName); do(["review", PackageString]) -> review(PackageString); do(["approve", PackageString]) -> PackageID = zx_lib:package_id(PackageString), approve(PackageID); do(["reject", PackageString]) -> PackageID = zx_lib:package_id(PackageString), reject(PackageID); do(["resign", PackageString]) -> resign(PackageString); do(["drop", "dep", PackageString]) -> PackageID = zx_lib:package_id(PackageString), drop_dep(PackageID); do(["drop", "key", KeyID]) -> drop_key(KeyID); do(["drop", "realm", Realm]) -> drop_realm(Realm); do(["verup", Level]) -> verup(Level); do(["package"]) -> {ok, TargetDir} = file:get_cwd(), package(TargetDir); do(["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; do(["submit", PackageFile]) -> submit(PackageFile); do(["dialyze"]) -> dialyze(); do(["create", "user", Realm, Name]) -> create_user(Realm, Name); do(["create", "keypair"]) -> create_keypair(); do(["create", "plt"]) -> create_plt(); do(["create", "realm"]) -> create_realm(); do(["create", "realmfile", Realm]) -> create_realmfile(Realm); do(["create", "sysop"]) -> create_sysop(); do(_) -> usage_exit(22). %%% Daemon Controls -spec subscribe(package()) -> ok | {error, Reason :: term()}. %% @doc %% Initiates the zx_daemon and instructs it to subscribe to a package. %% %% Any events in the Zomp network that apply to the subscribed package will be %% forwarded to the process that originally called subscribe/1. How the original %% caller reacts to these notifications is up to the author -- not reply or "ack" %% is expected. %% %% Package subscriptions can be used as the basis for user notification of updates, %% automatic upgrade restarts, package catalog tracking, etc. subscribe(Package) -> case application:start(?MODULE, normal) of ok -> zx_daemon:subscribe(Package); Error -> Error end. -spec unsubscribe() -> ok | {error, Reason :: term()}. %% @doc %% Unsubscribes from package updates. unsubscribe() -> zx_daemon:unsubscribe(). %%% Application Start/Stop -spec start() -> ok | {error, Reason :: term()}. %% @doc %% An alias for `application:ensure_started(zx)', meaning it is safe to call this %% function more than once, or within a system where you are unsure whether zx is %% already running (perhaps due to complex dependencies that require zx already). %% In the typical case this function does not ever need to be called, because the %% zx_daemon is always started in the background whenever an application is started %% using the command `zx run [app_id]'. %% @equiv application:ensure_started(zx). start() -> application:ensure_started(zx). -spec stop() -> ok | {error, Reason :: term()}. %% @doc %% A safe wrapper for `application:stop(zx)'. Similar to `ensure_started/1,2', returns %% `ok' in the case that zx is already stopped. stop() -> case application:stop(zx) of ok -> ok; {error, {not_started, zx}} -> ok; Error -> Error end. %%% Application Callbacks -spec start(StartType, StartArgs) -> Result when StartType :: normal, StartArgs :: [], Result :: {ok, pid()}. %% @private %% Application callback. Not to be called directly. start(normal, []) -> ok = application:ensure_started(inets), zx_daemon:start_link(). -spec stop(term()) -> ok. %% @private %% Application callback. Not to be called directly. stop(_) -> ok. %%% Execution of application -spec run(Identifier, RunArgs) -> no_return() when Identifier :: string(), RunArgs :: [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 must be a valid package string of the form `realm-appname[-version]' %% where the `realm()' and `name()' must follow Zomp 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, RunArgs) -> ok = file:set_cwd(zx_lib:zomp_home()), ok = start(), FuzzyID = case zx_lib:package_id(Identifier) of {ok, Fuzzy} -> Fuzzy; {error, invalid_package_string} -> error_exit("Bad package string: ~ts", [Identifier], ?LINE) end, {ok, PackageID} = ensure_installed(FuzzyID), ok = build(PackageID), Dir = zx_lib:package_dir(PackageID), {ok, Meta} = zx_lib:read_project_meta(Dir), execute(PackageID, Meta, Dir, RunArgs). %%% Execution of local project -spec run_local(RunArgs) -> no_return() when RunArgs :: [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(RunArgs) -> Meta = zx_lib:read_project_meta(), PackageID = maps:get(package_id, Meta), ok = build(), {ok, Dir} = file:get_cwd(), ok = file:set_cwd(zx_lib:zomp_home()), ok = start(), execute(PackageID, Meta, Dir, RunArgs). -spec execute(PackageID, Meta, Dir, RunArgs) -> no_return() when PackageID :: package_id(), Meta :: package_meta(), Dir :: file:filename(), RunArgs :: [string()]. %% @private %% Execution prep common to all packages. execute(PackageID, Meta, Dir, RunArgs) -> PackageString = zx_lib:package_string(PackageID), ok = log(info, "Preparing ~ts...", [PackageString]), Type = maps:get(type, Meta), Deps = maps:get(deps, Meta), case zx_daemon:fetch(Deps) of {{ok, _}, {error, []}} -> ok = lists:foreach(fun install/1, Deps), ok = lists:foreach(fun build/1, Deps), execute(Type, PackageID, Dir, Meta, RunArgs); {{ok, _}, {error, Errors}} -> error_exit("Failed package fetches: ~tp", [Errors], ?LINE) end. -spec execute(Type, PackageID, Meta, Dir, RunArgs) -> no_return() when Type :: app | lib, PackageID :: package_id(), Meta :: package_meta(), Dir :: file:filename(), RunArgs :: [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(app, PackageID, Meta, Dir, RunArgs) -> PackageString = zx_lib:package_string(PackageID), ok = log(info, "Starting ~ts.", [PackageString]), Name = element(2, PackageID), AppMod = list_to_atom(Name), ok = zx_daemon:pass_meta(Meta, Dir), ok = ensure_all_started(AppMod), ok = pass_argv(AppMod, RunArgs), log(info, "Launcher complete."); execute(lib, PackageID, _, _, _) -> Message = "Lib ~ts is available on the system, but is not a standalone app.", {ok, PackageString} = zx_lib:package_string(PackageID), ok = log(info, Message, [PackageString]), halt(0). -spec ensure_all_started(AppMod) -> ok when AppMod :: module(). %% @private %% Wrap a call to application:ensure_all_started/1 to selectively provide output %% in the case any dependencies are actually started by the call. Might remove this %% depending on whether SASL winds up becoming a standard part of the system and %% whether it becomes common for dependencies to all signal their own start states %% somehow. ensure_all_started(AppMod) -> case application:ensure_all_started(AppMod) of {ok, []} -> ok; {ok, Apps} -> log(info, "Started ~tp", [Apps]) end. -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. %%% Project initialization -spec initialize(Type, PackageString) -> no_return() when Type :: app | lib, PackageString :: string(). %% @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, PackageString) -> PackageID = case zx_lib:package_id(PackageString) of {ok, ID} -> ID; {error, invalid_package_string} -> error_exit("Invalid package string.", ?LINE) end, ok = log(info, "Initializing ~s...", [PackageString]), MetaList = [{package_id, PackageID}, {deps, []}, {type, Type}], Meta = maps:from_list(MetaList), ok = zx_lib:write_project_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_or_die(PackageFile), {ok, CWD} = file:get_cwd(), ok = file:set_cwd(zx_lib:zomp_home()), {"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files), Meta = binary_to_term(MetaBin), PackageID = maps:get(package_id, Meta), TgzFile = zx_lib: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", zx_lib:namify_zrp(PackageID)), file:copy(PackageFile, ZrpPath); false -> error_exit("Bad package signature: ~ts", [PackageFile], ?LINE) end, ok = file:set_cwd(CWD), Message = "~ts is now locally available.", {ok, PackageString} = zx_lib:package_string(PackageID), ok = log(info, Message, [PackageString]), halt(0). %%% Set dependency -spec set_dep(Identifier :: string()) -> 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(Identifier) -> {ok, {Realm, Name, FuzzyVersion}} = zx_lib:package_id(Identifier), Version = case FuzzyVersion of {z, z, z} -> ok = start(), {ok, V} = zx_daemon:query_latest({Realm, Name}), V; {X, Y, Z} when is_integer(X), is_integer(Y), is_integer(Z) -> {X, Y, Z}; _ -> ok = start(), {ok, V} = zx_daemon:query_latest({Realm, Name, FuzzyVersion}), V end, set_dep({Realm, Name}, Version). set_dep({Realm, Name}, Version) -> PackageID = {Realm, Name, Version}, {ok, Meta} = zx_lib:read_project_meta(), Deps = maps:get(deps, Meta), case lists:member(PackageID, Deps) of true -> {ok, PackageString} = zx_lib:package_string(PackageID), ok = log(info, "~ts is already a dependency", [PackageString]), halt(0); false -> set_dep(PackageID, Deps, Meta) end. -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", {ok, OldPS} = zx_lib:package_string({Realm, Name, OldVersion}), {ok, NewPS} = zx_lib:package_string({Realm, Name, NewVersion}), ok = log(info, Message, [OldPS, NewPS]), [PackageID | Rest]; {[], Deps} -> {ok, PackageString} = zx_lib:package_string(PackageID), ok = log(info, "Adding dep ~ts", [PackageString]), [PackageID | Deps] end, NewMeta = maps:put(deps, NewDeps, Meta), ok = zx_lib:write_project_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 -> fetch_one({Realm, Name, Version}) end. -spec fetch_one(PackageID) -> {ok, ActualID} | no_return() when PackageID :: package_id(), ActualID :: package_id(). %% @private %% A helper function to deal with the special case of downloading and installing a %% single primary application package with a possibly incomplete version designator. %% All other fetches are for arbitrarily long lists of package IDs with complete %% version numbers (dependency fetches). fetch_one(PackageID) -> case zx_daemon:fetch([PackageID]) of {{ok, [ActualID]}, {error, []}} -> ok = install(ActualID), {ok, ActualID}; {{ok, []}, {error, [{PackageID, Reason}]}} -> error_exit("Package fetch failed with: ~tp", [Reason], ?LINE) 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({Realm, Name, Version}) -> PackageDir = filename:join(["lib", Realm, Name]), case filelib:is_dir(PackageDir) of true -> resolve_installed_version(PackageDir, Version); false -> not_found end. resolve_installed_version(PackageDir, Version) -> DirStrings = filelib:wildcard("*", PackageDir), Versions = lists:fold(fun tuplize/2, [], DirStrings), zx_lib:find_latest_compatible(Version, Versions). tuplize(String, Acc) -> case zx_lib:string_to_version(String) of {ok, Version} -> [Version | Acc]; _ -> Acc end. %%% 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 zx_lib:string_to_version(VersionString) of {ok, {_, _, z}} -> Message = "'set version' arguments must be complete, ex: 1.2.3", error_exit(Message, ?LINE); {ok, Version} -> Version; {error, invalid_version_string} -> Message = "Invalid version string: ~tp", error_exit(Message, [VersionString], ?LINE) end, {ok, Meta} = zx_lib:read_project_meta(), {Realm, Name, OldVersion} = maps:get(package_id, Meta), update_version(Realm, Name, OldVersion, NewVersion, Meta). -spec update_version(Realm, Name, OldVersion, NewVersion, OldMeta) -> no_return() when Realm :: realm(), Name :: name(), OldVersion :: version(), NewVersion :: version(), OldMeta :: package_meta(). %% @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 = zx_lib:write_project_meta(NewMeta), OldVS = zx_lib:version_to_string(OldVersion), NewVS = zx_lib:version_to_string(NewVersion), ok = log(info, "Version changed from ~s to ~s.", [OldVS, NewVS]), 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_HOME. The realms will be printed to %% stdout and the program will exit. list_realms() -> Pattern = filename:join(zx_lib:zomp_home(), "*.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) -> ok = start(), case zx_daemon:list_packages(Realm) 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); {error, bad_realm} -> error_exit("Bad realm name.", ?LINE); {error, no_realm} -> error_exit("Realm \"~ts\" is not configured.", ?LINE); {error, network} -> Message = "Network issues are preventing connection to the realm.", error_exit(Message, ?LINE) end. -spec list_versions(PackageName :: string()) -> 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(PackageName) -> Package = {Realm, Name} = case zx_lib:package_id(PackageName) of {ok, {R, N, {z, z, z}}} -> {R, N}; {error, invalid_package_string} -> error_exit("~tp is not a valid package name.", [PackageName], ?LINE) end, ok = start(), case zx_daemon:list_versions(Package) of {ok, []} -> Message = "Package ~ts has no versions available.", ok = log(info, Message, [PackageName]), halt(0); {ok, Versions} -> Print = fun(Version) -> {ok, PackageString} = zx_lib:package_string({Realm, Name, Version}), io:format("~ts~n", [PackageString]) end, ok = lists:foreach(Print, Versions), halt(0); {error, bad_realm} -> error_exit("Bad realm name.", ?LINE); {error, bad_package} -> error_exit("Bad package name.", ?LINE); {error, network} -> Message = "Network issues are preventing connection to the realm.", error_exit(Message, ?LINE) end. -spec list_pending(PackageName :: string()) -> 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(PackageName) -> Package = {Realm, Name} = case zx_lib:package_id(PackageName) of {ok, {R, N, {z, z, z}}} -> {R, N}; {error, invalid_package_string} -> error_exit("~tp is not a valid package name.", [PackageName], ?LINE) end, ok = start(), case zx_daemon:list_pending(Package) of {ok, []} -> Message = "Package ~ts has no versions pending.", ok = log(info, Message, [PackageName]), halt(0); {ok, Versions} -> Print = fun(Version) -> {ok, PackageString} = zx_lib:package_string({Realm, Name, Version}), io:format("~ts~n", [PackageString]) end, ok = lists:foreach(Print, Versions), halt(0); {error, bad_realm} -> error_exit("Bad realm name.", ?LINE); {error, bad_package} -> error_exit("Bad package name.", ?LINE); {error, network} -> Message = "Network issues are preventing connection to the realm.", error_exit(Message, ?LINE) 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) -> ok = start(), case zx_daemon:list_resigns(Realm) of {ok, []} -> Message = "No packages pending signature in ~tp.", ok = log(info, Message, [Realm]), halt(0); {ok, PackageIDs} -> Print = fun(PackageID) -> {ok, PackageString} = zx_lib:package_string(PackageID), io:format("~ts~n", [PackageString]) end, ok = lists:foreach(Print, PackageIDs), halt(0); {error, bad_realm} -> error_exit("Bad realm name.", ?LINE); {error, no_realm} -> error_exit("Realm \"~ts\" is not configured.", ?LINE); {error, network} -> Message = "Network issues are preventing connection to the realm.", error_exit(Message, ?LINE) end. %%% Add realm -spec add_realm(Path) -> no_return() when Path :: file:filename(). %% @private %% Add a .realm file to $ZOMP_HOME from a location in the filesystem. %% Print the SHA512 of the .realm file for the user so they can verify that the file %% is authentic. This implies, of course, that .realm maintainers are going to %% post SHA512 sums somewhere visible. 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, zx_lib:zomp_home()}]) 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} -> error_exit("~ts is not a valid realm file.", [Path], ?LINE); {error, eof} -> error_exit("~ts is not a valid realm file.", [Path], ?LINE) end. -spec add_package(PackageName) -> no_return() when PackageName :: package(). %% @private %% A sysop command that adds a package to a realm operated by the caller. add_package(PackageName) -> ok = file:set_cwd(zx_lib:zomp_home()), case zx_lib:package_id(PackageName) of {ok, {Realm, Name, {z, z, z}}} -> add_package(Realm, Name); _ -> error_exit("~tp is not a valid package name.", [PackageName], ?LINE) end. -spec add_package(Realm, Name) -> no_return() when Realm :: realm(), Name :: name(). 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, _, _} = zx_lib: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, _, _} = zx_lib: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) -> {ok, PackageString} = zx_lib:package_string(PackageID), {ok, Meta} = zx_lib:read_project_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 = zx_lib:write_project_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(zx_lib:zomp_home()), KeyGlob = KeyName ++ ".{key,pub},der", Pattern = filename:join([zx_lib:zomp_home(), "key", Realm, KeyGlob]), 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(zx_lib:zomp_home()), RealmConf = zx_lib: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 = zx_lib: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([zx_lib:zomp_home(), "key", Realm]), case filelib:is_dir(KeyDir) of true -> rm_rf(KeyDir); 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") -> version_up(major); verup("minor") -> version_up(minor); verup("patch") -> version_up(patch); verup(_) -> usage_exit(22). -spec version_up(Level) -> no_return() when Level :: 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. version_up(Arg) -> {ok, Meta} = zx_lib:read_project_meta(), PackageID = maps:get(package_id, Meta), version_up(Arg, PackageID, Meta). -spec version_up(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). version_up(major, {Realm, Name, OldVersion = {Major, _, _}}, OldMeta) -> NewVersion = {Major + 1, 0, 0}, update_version(Realm, Name, OldVersion, NewVersion, OldMeta); version_up(minor, {Realm, Name, OldVersion = {Major, Minor, _}}, OldMeta) -> NewVersion = {Major, Minor + 1, 0}, update_version(Realm, Name, OldVersion, NewVersion, OldMeta); version_up(patch, {Realm, Name, OldVersion = {Major, Minor, Patch}}, OldMeta) -> NewVersion = {Major, Minor, Patch + 1}, update_version(Realm, Name, OldVersion, NewVersion, OldMeta). %%% 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]), {ok, Meta} = zx_lib:read_project_meta(TargetDir), {Realm, _, _} = maps:get(package_id, Meta), KeyDir = filename:join([zx_lib:zomp_home(), "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) -> {ok, Meta} = zx_lib:read_project_meta(TargetDir), PackageID = maps:get(package_id, Meta), true = element(1, PackageID) == element(1, KeyID), {ok, PackageString} = zx_lib: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_or_die(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). %%% Authenticated communication with prime -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, Reason} -> error_exit("Action failed with: ~tp", [Reason], ?LINE); Unexpected -> error_exit("Unexpected message: ~tp", [Unexpected], ?LINE) end; {tcp_closed, Socket} -> error_exit("Lost connection to node unexpectedly.", ?LINE) after 5000 -> error_exit("Connection timed out.", ?LINE) 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), Options = [{packet, 4}, {mode, binary}, {active, true}], case gen_tcp:connect(Host, Port, 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} -> ok = log(warning, "Socket closed unexpectedly."), halt(1) 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} -> ok = log(warning, "Socket closed unexpectedly."), halt(1) 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} -> ok = log(warning, "Socket closed unexpectedly."), halt(1) 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(zx_lib:zomp_home(), "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 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. %%% Key utilities -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([zx_lib:zomp_home(), "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([zx_lib:zomp_home(), "key", Realm, PrivateKeyFile]), filelib:is_regular(PrivateKeyPath). %%% 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 {zx_lib:valid_lower0_9(Realm), zx_lib: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(zx_lib:zomp_home()), KeyID = prompt_keygen(), case generate_rsa(KeyID) of {ok, _, _} -> halt(0); Error -> error_exit("create_keypair/0 error: ~tp", [Error], ?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([zx_lib:zomp_home(), "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 -> error_exit("~ts already exists! Halting.", [Path], ?LINE); 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", ?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 -> KeyDer = KeyName ++ ".key.der", P = filename:join([zx_lib:zomp_home(), "key", Realm, KeyDer]), {'RSAPrivateKey', P}; public -> PubDer = KeyName ++ ".pub.der", P = filename:join([zx_lib:zomp_home(), "key", Realm, PubDer]), {'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). -spec build_plt() -> ok. %% @private %% Build a general plt file for Dialyzer based on the core Erland distro. %% TODO: Make a per-package + dependencies version of this. 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). -spec default_plt() -> file:filename(). default_plt() -> filename:join(zx_lib:zomp_home(), "basic.plt"). %%% Dialyze -spec dialyze() -> no_return(). %% @private %% Preps a copy of this script for typechecking with Dialyzer. %% TODO: Create a package_id() based version of this to handle dialyzation of complex %% projects. 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(zx_lib:zomp_home(), "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() -> 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 zx_lib:valid_lower0_9(Realm) of true -> RealmFile = filename:join(zx_lib:zomp_home(), Realm ++ ".realm"), case filelib:is_regular(RealmFile) of false -> create_realm(Realm); true -> ok = io:format("That realm already exists. Be more original.~n"), create_realm() end; false -> ok = io:format("Bad realm name \"~ts\". Try again.~n", [Realm]), create_realm() end. -spec create_realm(Realm) -> no_return() when Realm :: realm(). create_realm(Realm) -> ExAddress = prompt_external_address(), create_realm(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 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(Realm, ExAddress) -> no_return() when Realm :: realm(), ExAddress :: inet:hostname() | inet:ip_address(). create_realm(Realm, ExAddress) -> 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(11311), create_realm(Realm, ExAddress, ExPort). -spec create_realm(Realm, ExAddress, ExPort) -> no_return() when Realm :: realm(), ExAddress :: inet:hostname() | inet:ip_address(), ExPort :: inet:port_number(). create_realm(Realm, ExAddress, ExPort) -> 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(11311), create_realm(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(Realm, ExAddress, ExPort, InPort) -> no_return() when Realm :: realm(), ExAddress :: inet:hostname() | inet:ip_address(), ExPort :: inet:port_number(), InPort :: inet:port_number(). create_realm(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 zx_lib:valid_lower0_9(UserName) of true -> create_realm(Realm, ExAddress, ExPort, InPort, UserName); false -> ok = io:format("Bad username ~tp. Try again.~n", [UserName]), create_realm(Realm, ExAddress, ExPort, InPort) end. -spec create_realm(Realm, ExAddress, ExPort, InPort, UserName) -> no_return() when Realm :: realm(), ExAddress :: inet:hostname() | inet:ip_address(), ExPort :: inet:port_number(), InPort :: inet:port_number(), UserName :: string(). create_realm(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 {zx_lib:valid_lower0_9(User), zx_lib:valid_label(Host)} of {true, true} -> create_realm(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(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(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(Realm, ExAddress, ExPort, InPort, UserName) end. -spec create_realm(Realm, ExAddress, ExPort, InPort, UserName, Email) -> no_return() when Realm :: realm(), ExAddress :: inet:hostname() | inet:ip_address(), ExPort :: inet:port_number(), InPort :: inet:port_number(), UserName :: string(), Email :: string(). create_realm(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(Realm, ExAddress, ExPort, InPort, UserName, Email, RealName). -spec create_realm(Realm, ExAddress, ExPort, InPort, UserName, Email, RealName) -> no_return() when Realm :: realm(), ExAddress :: inet:hostname() | inet:ip_address(), ExPort :: inet:port_number(), InPort :: inet:port_number(), UserName :: string(), Email :: string(), RealName :: string(). create_realm(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}], {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, PublicZRF = filename:join(CWD, Realm ++ ".zrf"), RealmFN = Realm ++ ".realm", ok = zx_lib:write_terms(RealmFN, RealmSettings), ok = KeyCopy(PackagePub), ok = KeyCopy(RealmPub), ok = erl_tar:create(PublicZRF, [RealmFN, "key"], [compressed]), PrimeZRF = filename:join(CWD, Realm ++ ".zpf"), ok = KeyCopy(SysopPub), ok = zx_lib:write_terms("zomp.conf", ZompSettings), ok = erl_tar:create(PrimeZRF, [RealmFN, "zomp.conf", "key"], [compressed]), KeyBundle = filename:join(CWD, Realm ++ ".zkf"), ok = lists:foreach(KeyCopy, [PackageKey, RealmKey, SysopKey]), 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~n" "to 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(zx_lib:zomp_home()), KeyPath = fun({R, K}) -> filename:join(["key", R, K ++ ".pub.der"]) end, RealmKeyPaths = lists:map(KeyPath, RealmKeyIDs), PackageKeyPaths = lists:map(KeyPath, PackageKeyIDs), Targets = [zx_lib: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). %%% Package utilities -spec install(package_id()) -> ok. %% @private %% Install a package from the cache into the local system. %% Before calling this function it must be known that: %% - The zrp file is in the cache %% - The zrp file is valid %% - This function will only be called on startup by the launch process %% - The package is not already installed %% - If this function crashes it will completely halt the system install(PackageID) -> {ok, PackageString} = zx_lib:package_string(PackageID), ok = log(info, "Installing ~ts", [PackageString]), ZrpFile = filename:join("zrp", zx_lib:namify_zrp(PackageID)), Files = extract_zrp_or_die(ZrpFile), TgzFile = zx_lib: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 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. %% Should only ever be called by the initial launch process. verify(Data, Signature, PubKey) -> case public_key:verify(Data, sha512, Signature, PubKey) of true -> ok; false -> error_exit("Bad package signature!", ?LINE) end. -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(zx_lib:package_dir(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. %%% 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 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. -spec rm_rf(file:filename()) -> ok | {error, file:posix()}. %% @private %% Recursively remove files and directories. Equivalent to `rm -rf'. 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. -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 = {Realm, Name, _}) -> Package = {Realm, Name}, PackageHome = zx_lib:package_dir(PackageID), PackageData = zx_lib:package_dir("var", Package), PackageConf = zx_lib:package_dir("etc", Package), 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 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 extract_zrp_or_die(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_or_die(FileName) -> case erl_tar:extract(FileName, [memory]) of {ok, Files} -> Files; {error, {FileName, enoent}} -> Message = "Can't find file ~ts.", error_exit(Message, [FileName], ?LINE); {error, invalid_tar_checksum} -> Message = "~ts is not a valid zrp archive.", error_exit(Message, [FileName], ?LINE); {error, Reason} -> Message = "Extracting package file failed with: ~160tp.", error_exit(Message, [Reason], ?LINE) end. -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) -> case zx_lib:load_realm_conf(Realm) 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 add sysop UserID~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, Line) -> no_return() when Error :: term(), Line :: non_neg_integer(). %% @private %% Format an error message in a way that makes it easy to locate. error_exit(Error, Line) -> error_exit(Error, [], Line). -spec error_exit(Format, Args, Line) -> no_return() when Format :: string(), Args :: [term()], Line :: non_neg_integer(). %% @private %% Format an error message in a way that makes it easy to locate. error_exit(Format, Args, Line) -> File = filename:basename(?FILE), ok = log(error, "~ts:~tp: " ++ Format, [File, Line | Args]), halt(1).