-module(grisp_update_packager). %--- Includes ------------------------------------------------------------------ -include_lib("kernel/include/file.hrl"). -include_lib("public_key/include/public_key.hrl"). %--- Exports ------------------------------------------------------------------- %% API functions -export([package/2]). %--- Types --------------------------------------------------------------------- -type update_file() :: #{ name := iodata(), target := iodata(), local := iodata(), url => iodata() | undefined }. -type mbr_partition() :: #{ role := boot | system | data, type => dos | fat | fat32 | undefined, % dos is deprecated start => non_neg_integer(), size := non_neg_integer() }. -type mbr_partition_table() :: [mbr_partition()]. -type gpt_partition() :: #{ role := boot | system | data, type => linux | swap | home | efi | raid | llvm | bdp | binary() | string(), id := iodata(), start => non_neg_integer(), size := non_neg_integer() }. -type gpt_partition_table() :: [gpt_partition()]. -type package_options() :: #{ tarball => boolean(), name := iodata() | undefined, version := iodata() | undefined, manifest := term() | undefined, description => iodata() | undefined, architecture => string() | binary() | undefined, block_size => pos_integer(), key_file => iodata() | undefined, key => public_key:pem_entry(), system => iodata() | undefined, bootloader => iodata() | undefined, files => [update_file()], mbr => mbr_partition_table(), gpt => gpt_partition_table() }. %--- Macros -------------------------------------------------------------------- -define(DEFAULT_ARCHITECTURE, <<"arm-grisp2-rtems">>). -define(DEFAULT_BLOCK_SIZE, 4194304). -define(MIN_ZIP_PERCENT, 3). %--- API Functions ------------------------------------------------------------- %% @doc Creates a GRiSP software update package. %% The output path is either a directory where all the package files will be %% stored, or a tarball file name that will contain all the package files. %% In both cases the directory or the file must not already exist. %%

Options: %%

-spec package(OutputPath :: iodata(), package_options()) -> ok | {error, Reason :: term()}. package(OutputPath0, Opts0) -> try OutputPath = check_does_not_exist(OutputPath0), Opts = validate_options(Opts0), build_package(OutputPath, Opts) catch throw:Reason -> {error, Reason} end. %--- Internal Functions -------------------------------------------------------- validate_options(Opts0) -> Opts = lists:foldl(fun({Tag, CheckFun, Default}, Map) -> case maps:find(Tag, Map) of {ok, Value} when Value =/= undefined, CheckFun =/= undefined -> Map#{Tag => CheckFun(Value)}; {ok, Value} when Value =/= undefined -> Map#{Tag => Value}; _ -> Map#{Tag => Default} end end, Opts0, [ {tarball, fun check_boolean/1, true}, {name, fun check_string/1, undefined}, {version, fun check_string/1, undefined}, {description, fun check_string/1, undefined}, {architecture, fun check_string/1, ?DEFAULT_ARCHITECTURE}, {manifest, undefined, undefined}, {block_size, fun check_pos_integer/1, ?DEFAULT_BLOCK_SIZE}, {key_file, fun check_file_exists/1, undefined}, {key, fun check_private_key/1, undefined}, {system, fun check_file_exists/1, undefined}, {bootloader, fun check_file_exists/1, undefined}, {mbr, fun check_mbr/1, undefined}, {gpt, fun check_gpt/1, undefined}, {files, fun check_update_files/1, []} ]), required(Opts, name), required(Opts, version), require_one(Opts, [mbr, gpt]), require_only_one(Opts, [key_file, key]), case Opts of #{key_file := KeyFile, key := undefined} when KeyFile =/= undefined -> Opts#{key => termseal:load_private_key(KeyFile)}; _ -> Opts end. required(Opts, Name) -> case maps:find(Name, Opts) of {ok, _} -> ok; error -> throw({missing_option, Name}) end. require_one(Opts, Names) -> case [V || V <- maps:values(maps:with(Names, Opts)), V =/= undefined] of [_] -> ok; [] -> throw({missing_option, Names}); _ -> throw({conflicting_options, Names}) end. require_only_one(Opts, Names) -> case [V || V <- maps:values(maps:with(Names, Opts)), V =/= undefined] of [_] -> ok; [] -> ok; _ -> throw({conflicting_options, Names}) end. check_file_exists(Path0) -> Path = check_string(Path0), case file:read_file_info(Path) of {error, enoent} -> throw({missing_file, Path}); {ok, #file_info{type = regular}} -> Path end. check_does_not_exist(Path0) -> Path = check_string(Path0), case file:read_file_info(Path) of {error, enoent} -> Path; {ok, #file_info{}} -> throw({already_exists, Path}) end. check_boolean(Value) when is_boolean(Value) -> Value; check_boolean(BadValue) -> throw({bad_boolean, BadValue}). check_string(Value) when is_list(Value); is_binary(Value) -> iolist_to_binary(Value); check_string(BadValue) -> throw({bad_iodata, BadValue}). check_pos_integer(Value) when is_integer(Value), Value > 0 -> Value; check_pos_integer(BadValue) -> throw({bad_pos_integer, BadValue}). check_uuid(Value) -> try uuid:string_to_uuid(Value) catch error:badarg -> throw({bad_uuid, Value}) end. check_url(Value) -> case uri_string:parse(check_string(Value)) of #{scheme := Schem} when Schem =:= <<"http">>; Schem =:= <<"https">> -> Value; _ -> throw({bad_url, Value}) end. check_private_key(#'ECPrivateKey'{} = Key) -> Key; check_private_key(#'RSAPrivateKey'{} = Key) -> Key; check_private_key(_Key) -> throw(bad_signing_key). check_mbr(Value) when is_list(Value), length(Value) =< 4 -> check_mbr_partitions(Value, 0, []); check_mbr(BadValue) -> throw({too_much_mbr_partitions, BadValue}). check_partition_offset(Value, ErrorTag) -> Result = check_pos_integer(Value), case Result rem 512 of 0 -> Result; _ -> throw({ErrorTag, Value}) end. check_mbr_partitions([], _, Acc) -> lists:reverse(Acc); check_mbr_partitions([#{role := Role0, start := Start0, size := Size0} = Part | Rest], Min, Acc) -> Role = case Role0 of R when R =:= boot; R =:= system; R =:= data -> R; O -> throw({bad_partition_role, O}) end, Type = case maps:get(type, Part, fat) of dos -> fat; fat -> fat; fat32 -> fat; _ -> throw(mbr_partition_type_not_supported) end, Start = check_partition_offset(Start0, bad_partition_start), Size = check_partition_offset(Size0, bad_partition_size), case Start < Min of true -> throw(partition_overlap); _ -> ok end, check_mbr_partitions(Rest, Start + Size, [#{role => Role, type => Type, start => Start, size => Size} | Acc]); check_mbr_partitions([#{role := _, size := _} = Part | Rest], Min, Acc) -> check_mbr_partitions([Part#{start => Min} | Rest], Min, Acc); check_mbr_partitions([Part | _Rest], _Min, _Acc) -> throw({bad_mbr_partition, Part}). check_gpt(Value) when is_list(Value) -> check_gpt_partitions(Value, 0, []). check_gpt_partitions([], _, Acc) -> lists:reverse(Acc); check_gpt_partitions([#{role := Role0, type := Type0, id := Id0, start := Start0, size := Size0} | Rest], Min, Acc) -> Role = case Role0 of R when R =:= boot; R =:= system; R =:= data -> R; O -> throw({bad_partition_role, O}) end, Type = check_uuid(gpt_type(Type0)), Id = check_uuid(Id0), Start = check_partition_offset(Start0, bad_partition_start), Size = check_partition_offset(Size0, bad_partition_size), case Start < Min of true -> throw(partition_overlap); _ -> ok end, check_gpt_partitions(Rest, Start + Size, [#{role => Role, type => Type, id => Id, start => Start, size => Size} | Acc]); check_gpt_partitions([#{role := _, type := _, id := _, size := _} = Part | Rest], Min, Acc) -> check_gpt_partitions([Part#{start => Min} | Rest], Min, Acc); check_gpt_partitions([Part | _Rest], _Min, _Acc) -> throw({bad_gpt_partition, Part}). gpt_type(linux) -> "0fc63daf-8483-4772-8e79-3d69d8477de4"; % Linux Filesytem gpt_type(swap) -> "0657fd6d-a4ab-43c4-84e5-0933c84b4f4f"; % Linux Swap gpt_type(home) -> "933ac7e1-2eb4-4f13-b844-0e14e2aef915"; % Linux home gpt_type(efi) -> "c12a7328-f81f-11d2-ba4b-00a0c93ec93b"; % EFI system partition gpt_type(raid) -> "a19d880f-05fc-4d3b-a006-743f0f84911e"; % Linux Raid gpt_type(llvm) -> "e6d6d379-f507-44c2-a23c-238f2a3df928"; % Linux LLVM gpt_type(bdp) -> "ebd0a0a2-b9e5-4433-87c0-68b6b72699c7"; % Widndows Basic Data Partition gpt_type(Other) -> Other. check_update_files(FileSpecs) when is_list(FileSpecs) -> [check_update_file(S) || S <- FileSpecs]. check_update_file(#{name := Name0, target := Target0, local := Local0} = Spec) -> Url = case maps:find(url, Spec) of {ok, Url0} when Url0 =/= undefined -> check_url(Url0); _ -> undefined end, Name = check_string(Name0), Target = check_string(Target0), Local = check_file_exists(Local0), #{name => Name, target => Target, local => Local, url => Url}; check_update_file(Spec) -> throw({bad_file_spec, Spec}). build_package(OutputPath, Opts) -> Output = output_init(OutputPath, Opts), try Manifest = create_manifest(Output, Opts), save_plain_manifest(Output, Manifest, Opts), save_sealed_manifest(Output, Manifest, Opts) after output_terminate(Output) end. output_init(OutputPath, Opts) when is_binary(OutputPath) -> case maps:get(tarball, Opts) of true -> ok = filelib:ensure_dir(OutputPath), case erl_tar:open(OutputPath, [write]) of {ok, TarDesc} -> {tar, TarDesc}; {error, Reason} -> throw({tarball_open_error, OutputPath, Reason}) end; false -> ok = filelib:ensure_dir(filename_join([OutputPath, "."])), {dir, OutputPath} end. filename_join([V]) -> V; filename_join([_|_] = Parts) -> filename:join(Parts). write_file({dir, BaseDir}, Filepath, Data) -> FullPath = filename_join([BaseDir | Filepath]), ok = filelib:ensure_dir(FullPath), case file:write_file(FullPath, Data) of {error, Reason} -> throw({write_error, Reason}); ok -> ok end; write_file({tar, TarDesc}, Filepath, Data) -> TarPath = filename_join(Filepath), case erl_tar:add(TarDesc, iolist_to_binary(Data), binary_to_list(TarPath), []) of {error, Reason} -> throw({write_error, Reason}); ok -> ok end. output_terminate({dir, _}) -> ok; output_terminate({tar, TarDesc}) -> erl_tar:close(TarDesc). save_plain_manifest(Output, Manifest, _Opts) -> ManifestPath = [<<"MANIFEST">>], ManifestText = io_lib:format("%% coding: utf-8~n~tp.~n", [Manifest]), ManifestData = unicode:characters_to_binary(ManifestText), write_file(Output, ManifestPath, ManifestData). save_sealed_manifest(Output, Manifest, #{key := Key}) -> Box = termseal:seal(Manifest, Key), SealedManifestPath = [<<"MANIFEST.sealed">>], write_file(Output, SealedManifestPath, Box). create_manifest(Output, Opts) -> #{name := Name, architecture := Arch, version := VersionOpt} = Opts, Version = parse_version(VersionOpt), Manifest = case maps:get(manifest, Opts, undefined) of undefined -> []; Term -> [{manifest, Term}] end, RevManifest = structure(Opts) ++ Manifest ++ [ {architecture, iolist_to_binary(Arch)}, {description, unicode:characters_to_binary(maps:get(desc, Opts, ""))}, {version, Version}, {product, unicode:characters_to_binary(Name)}, {format, {1, 0, 0}} ], Objs = bootloader_objects(Opts, Output, []), Objs2 = system_objects(Opts, Output, Objs), Objs3 = firmware_objects(Opts, Output, Objs2), RevManifest2 = [{objects, lists:reverse(Objs3)} | RevManifest], lists:reverse(RevManifest2). structure(#{mbr := PartSpecs}) -> [{structure, {mbr, [ {sector_size, 512}, {partitions, partitions_mbr(PartSpecs, 0, [])} ]}}]; structure(#{gpt := PartSpecs}) -> [{structure, {gpt, [ {sector_size, 512}, {partitions, partitions_gpt(PartSpecs, 0, [])} ]}}]. partitions_mbr([], _Id, Acc) -> lists:reverse(Acc); partitions_mbr([#{role := system, type := T0, start := B, size := S} | Rest], Id, Acc) -> T = backcomp_mpr_type(T0), Item = {system, [{type, T}, {id, Id}, {start, B div 512}, {size, S div 512}]}, partitions_mbr(Rest, Id + 1, [Item | Acc]); partitions_mbr([#{role := R, type := T0, start := B, size := S} | Rest], Id, Acc) -> T = backcomp_mpr_type(T0), Item = {R, [{type, T}, {start, B div 512}, {size, S div 512}]}, partitions_mbr(Rest, Id, [Item | Acc]). backcomp_mpr_type(fat) -> dos; backcomp_mpr_type(Type) -> Type. partitions_gpt([], _Id, Acc) -> lists:reverse(Acc); partitions_gpt([#{role := system, type := T, id := U, start := B, size := S} | Rest], Id, Acc) -> Item = {system, [{type, uuid2bin(T)}, {id, Id}, {uuid, uuid2bin(U)}, {start, B div 512}, {size, S div 512}]}, partitions_gpt(Rest, Id + 1, [Item | Acc]); partitions_gpt([#{role := R, type := T, id := U, start := B, size := S} | Rest], Id, Acc) -> Item = {R, [{type, uuid2bin(T)}, {uuid, uuid2bin(U)}, {start, B div 512}, {size, S div 512}]}, partitions_gpt(Rest, Id, [Item | Acc]). uuid2bin(UUID) -> iolist_to_binary(uuid:uuid_to_string(UUID)). bootloader_objects(#{bootloader := Filepath}, Output, Objs) when Filepath =/= undefined -> {ok, Data} = file:read_file(Filepath), Block = zlib:gzip(Data), BlockPath = [<<"bootloader.gz">>], write_file(Output, BlockPath, Block), [{bootloader, [ {actions, [setup, bootloader]}, {product, <<"barebox">>}, {description, <<"Barebox Bootloader">>}, {target, {raw, [{context, global}, {offset, 0}]}}, {content, [ {block, [ {data_offset, 0}, {data_size, byte_size(Data)}, {data_hashes, [ {sha256, base64:encode(crypto:hash(sha256, Data))}, {crc32, erlang:crc32(Data)} ]}, {block_format, gzip}, {block_size, byte_size(Block)}, {block_hashes, [ {sha256, base64:encode(crypto:hash(sha256, Block))}, {crc32, erlang:crc32(Block)} ]}, {block_path, <<"bootloader.gz">>} ]} ]} ]} | Objs]; bootloader_objects(_Opts, _Output, Objs) -> Objs. system_objects(#{files := SysFileSpecs} = Opts, _Output, Objs) -> system_objects(Opts, _Output, SysFileSpecs, Objs). system_objects(_Opts, _Output, [], Objs) -> Objs; system_objects(Opts, Output, [#{url := Url} = Spec | Rest], Objs) -> #{name := Name, local := Local, target := Target} = Spec, Blocks = generate_refblocks(Opts, Local, Url), Objs2 = [{binary_to_atom(Name), [ {actions, [setup, update]}, {target, {file, [{context, system}, {path, Target}]}}, {content, Blocks} ]} | Objs], system_objects(Opts, Output, Rest, Objs2); system_objects(Opts, Output, [Spec | Rest], Objs) -> #{name := Name, local := Local, target := Target} = Spec, Blocks = generate_blocks(Opts, Output, Local, Name), Objs2 = [{binary_to_atom(Name), [ {actions, [setup, update]}, {target, {file, [{context, system}, {path, Target}]}}, {content, Blocks} ]} | Objs], system_objects(Opts, Output, Rest, Objs2). firmware_objects(#{system := Filepath} = Opts, Output, Objs) when Filepath =/= undefined -> Blocks = generate_blocks(Opts, Output, Filepath, <<"rootfs">>), [{rootfs, [ {actions, [setup, update]}, {target, {raw, [{context, system}, {offset, 0}]}}, {content, Blocks} ]} | Objs]. file_info(Filename) -> case file:open(Filename, [raw, read, binary]) of {error, Reason} -> throw({open_error, Filename, Reason}); {ok, File} -> try {Size, Crc32, HashCtx} = file_info(Filename, File, 0, erlang:crc32(<<>>), crypto:hash_init(sha256)), {Size, Crc32, crypto:hash_final(HashCtx)} after file:close(File) end end. file_info(Filename, File, Size, Crc32, HashCtx) -> case file:read(File, 256 * 1024) of eof -> {Size, Crc32, HashCtx}; {ok, Data} -> file_info(Filename, File, Size + byte_size(Data), erlang:crc32(Crc32, Data), crypto:hash_update(HashCtx, Data)); {error, Reason} -> throw({read_error, Filename, Reason}) end. generate_refblocks(_Opts, InputFilename, Url) -> {DataSize, DataCrc, DataHashBin} = file_info(InputFilename), [{block, [ {data_offset, 0}, {data_size, DataSize}, {data_hashes, [ {sha256, base64:encode(DataHashBin)}, {crc32, DataCrc} ]}, {block_format, raw}, {block_path, Url} ]}]. generate_blocks(#{block_size := Size}, Output, InputFilename, SubDir) -> case file:open(InputFilename, [read, raw, binary]) of {ok, File} -> generate_blocks_loop(Output, Size, File, SubDir, 0, 0, #{}, []); {error, Reason} -> throw({open_error, InputFilename, Reason}) end. generate_blocks_loop(Output, ReadSize, File, SubDir, Index, Offset, Cache, Blocks) -> case file:read(File, ReadSize) of eof -> lists:reverse(Blocks); {ok, Data} -> DataSize = byte_size(Data), DataBinHash = crypto:hash(sha256, Data), BlockSpec1 = [ {data_size, DataSize}, {data_hashes, [ {sha256, base64:encode(DataBinHash)}, {crc32, erlang:crc32(Data)} ]} ], {Cache2, Index2, BlockSpec} = case maps:find(DataBinHash, Cache) of {ok, Spec} -> {Cache, Index, Spec}; error -> ZipBlock = zlib:gzip(Data), ZipSize = byte_size(ZipBlock), MaxSize = DataSize - (DataSize * ?MIN_ZIP_PERCENT div 100), {Path, Block, Spec} = case ZipSize =< MaxSize of true -> Filename = iolist_to_binary(io_lib:format("~3..0b.gz", [Index])), RelPath = [SubDir, Filename], BinHash = crypto:hash(sha256, ZipBlock), ZipHash = base64:encode(BinHash), ZipCrc = erlang:crc32(ZipBlock), BlockSpec2 = BlockSpec1 ++ [ {block_format, gzip}, {block_size, ZipSize}, {block_hashes, [ {sha256, ZipHash}, {crc32, ZipCrc} ]}, {block_path, filename_join(RelPath)} ], {RelPath, ZipBlock, BlockSpec2}; false -> Filename = iolist_to_binary(io_lib:format("~3..0b", [Index])), RelPath = [SubDir, Filename], BlockSpec2 = BlockSpec1 ++ [ {block_format, raw}, {block_path, filename_join(RelPath)} ], {RelPath, Data, BlockSpec2} end, write_file(Output, Path, Block), {Cache#{DataBinHash => Spec}, Index + 1, Spec} end, generate_blocks_loop(Output, ReadSize, File, SubDir, Index2, Offset + DataSize, Cache2, [ {block, [{data_offset, Offset} | BlockSpec]} | Blocks]) end. parse_version({A, B, C}) when is_integer(A), A >= 0, is_integer(B), B >= 0, is_integer(C), C >= 0 -> {A, B, C}; parse_version(N) when is_integer(N) -> {N, 0, 0}; parse_version(Str) when is_list(Str) -> parse_version(unicode:characters_to_binary(Str)); parse_version(Bin) when is_binary(Bin) -> case re:run(Bin, "^([0-9]*)\.([0-9]*)\.([0-9]*)$", [{capture, all, binary}]) of {match, [_, A, B, C]} -> {binary_to_integer(A), binary_to_integer(B), binary_to_integer(C)}; _ -> Bin end.