%%%------------------------------------------------------------------- %%% @doc %%% Content manifest module for Macula content-addressed storage. %%% %%% Manifests describe content: name, size, chunk information, and %%% Merkle root hash for verification. They are identified by an MCID %%% (Macula Content Identifier) which is a self-describing hash. %%% %%% == MCID Format == %%% MCIDs are 34-byte binary values: %%% - Byte 0: Version (currently 0x01) %%% - Byte 1: Codec (0x55=raw, 0x56=manifest) %%% - Bytes 2-33: 32-byte hash (BLAKE3 or SHA256) %%% %%% == String Representation == %%% MCIDs can be converted to human-readable strings: %%% `mcid1-manifest-blake3-5d41402abc4b2a76b9719d911017c592' %%% %%% == Example Usage == %%% ``` %%% %% Create manifest from binary data %%% {ok, Manifest} = macula_content_manifest:create(Data, #{ %%% name => <<"my_app-1.0.0.tar.gz"/utf8>>, %%% chunk_size => 262144 %%% }). %%% %%% %% Get MCID as string %%% MCIDString = macula_content_manifest:mcid_to_string(maps:get(mcid, Manifest)). %%% %%% %% Verify data matches manifest %%% ok = macula_content_manifest:verify(Manifest, Data). %%% ''' %%% @end %%%------------------------------------------------------------------- -module(macula_content_manifest). %% API -export([ create/2, encode/1, decode/1, verify/2, mcid_to_string/1, mcid_from_string/1, get_chunk_mcid/2, version/0 ]). %% Types -type mcid() :: <<_:272>>. %% 34 bytes -type manifest() :: #{ mcid := mcid(), version := pos_integer(), name := binary(), size := non_neg_integer(), created := pos_integer(), chunk_size := pos_integer(), chunk_count := non_neg_integer(), hash_algorithm := macula_content_hasher:algorithm(), root_hash := binary(), chunks := [macula_content_chunker:chunk_info()], signature => binary(), publisher_did => binary() }. -export_type([mcid/0, manifest/0]). %% Constants -define(VERSION, 1). -define(CODEC_RAW, 16#55). -define(CODEC_MANIFEST, 16#56). %%%=================================================================== %%% API Functions %%%=================================================================== %% @doc Return the current manifest version. -spec version() -> pos_integer(). version() -> ?VERSION. %% @doc Create a manifest from binary data. %% %% Options: %% -spec create(binary(), map()) -> {ok, manifest()}. create(Data, Opts) -> Name = maps:get(name, Opts, <<"unnamed">>), ChunkSize = maps:get(chunk_size, Opts, macula_content_chunker:default_chunk_size()), Algorithm = maps:get(hash_algorithm, Opts, blake3), %% Chunk the data {ok, Chunks} = macula_content_chunker:chunk(Data, ChunkSize), ChunkInfos = macula_content_chunker:chunk_info(Chunks, Algorithm), %% Compute Merkle root RootHash = case ChunkInfos of [] -> macula_content_hasher:hash(Algorithm, <<>>); _ -> macula_content_chunker:merkle_root(ChunkInfos, Algorithm) end, %% Build manifest map (without MCID first) ManifestData = #{ version => ?VERSION, name => Name, size => byte_size(Data), created => erlang:system_time(second), chunk_size => ChunkSize, chunk_count => length(ChunkInfos), hash_algorithm => Algorithm, root_hash => RootHash, chunks => ChunkInfos }, %% Compute MCID from manifest content MCID = compute_mcid(ManifestData, Algorithm), {ok, ManifestData#{mcid => MCID}}. %% @doc Encode manifest to binary (MessagePack). -spec encode(manifest()) -> {ok, binary()} | {error, term()}. encode(Manifest) -> %% Convert to encodable format Encodable = manifest_to_encodable(Manifest), case msgpack:pack(Encodable, [{map_format, map}]) of {error, Reason} -> {error, Reason}; Packed -> {ok, Packed} end. %% @doc Decode manifest from binary. -spec decode(binary()) -> {ok, manifest()} | {error, invalid_manifest}. decode(Binary) -> case msgpack:unpack(Binary, [{map_format, map}]) of {ok, Map} -> decode_manifest(Map); {error, _} -> {error, invalid_manifest} end. %% @doc Verify that data matches the manifest. -spec verify(manifest(), binary()) -> ok | {error, size_mismatch | root_hash_mismatch}. verify(Manifest, Data) -> Size = maps:get(size, Manifest), case byte_size(Data) of Size -> verify_root_hash(Manifest, Data); _ -> {error, size_mismatch} end. %% @doc Convert MCID to human-readable string. -spec mcid_to_string(mcid()) -> binary(). mcid_to_string(<>) -> VersionStr = integer_to_binary(Version), CodecStr = codec_to_string(Codec), AlgoStr = <<"blake3">>, %% Assume blake3 for now HashHex = macula_content_hasher:hex_encode(Hash), <<"mcid", VersionStr/binary, "-", CodecStr/binary, "-", AlgoStr/binary, "-", HashHex/binary>>. %% @doc Parse MCID from string. -spec mcid_from_string(binary()) -> {ok, mcid()} | {error, invalid_mcid}. mcid_from_string(String) -> case parse_mcid_string(String) of {ok, Version, Codec, Hash} -> {ok, <>}; error -> {error, invalid_mcid} end. %% @doc Get MCID for a specific chunk. -spec get_chunk_mcid(manifest(), non_neg_integer()) -> {ok, mcid()} | {error, invalid_index}. get_chunk_mcid(Manifest, Index) -> Chunks = maps:get(chunks, Manifest), case Index < length(Chunks) of true -> ChunkInfo = lists:nth(Index + 1, Chunks), Hash = maps:get(hash, ChunkInfo), MCID = make_mcid(?VERSION, ?CODEC_RAW, Hash), {ok, MCID}; false -> {error, invalid_index} end. %%%=================================================================== %%% Internal Functions %%%=================================================================== %% @private compute_mcid(ManifestData, Algorithm) -> %% Create deterministic encoding of manifest (sorted keys, no timestamps for MCID) Canonical = #{ <<"name">> => maps:get(name, ManifestData), <<"size">> => maps:get(size, ManifestData), <<"chunk_size">> => maps:get(chunk_size, ManifestData), <<"chunk_count">> => maps:get(chunk_count, ManifestData), <<"hash_algorithm">> => atom_to_binary(maps:get(hash_algorithm, ManifestData)), <<"root_hash">> => maps:get(root_hash, ManifestData) }, Packed = msgpack:pack(Canonical, [{map_format, map}]), Hash = macula_content_hasher:hash(Algorithm, Packed), make_mcid(?VERSION, ?CODEC_MANIFEST, Hash). %% @private make_mcid(Version, Codec, Hash) -> <>. %% @private codec_to_string(?CODEC_RAW) -> <<"raw">>; codec_to_string(?CODEC_MANIFEST) -> <<"manifest">>; codec_to_string(_) -> <<"unknown">>. %% @private string_to_codec(<<"raw">>) -> {ok, ?CODEC_RAW}; string_to_codec(<<"manifest">>) -> {ok, ?CODEC_MANIFEST}; string_to_codec(_) -> error. %% @private parse_mcid_string(<<"mcid", Rest/binary>>) -> case binary:split(Rest, <<"-">>, [global]) of [VersionStr, CodecStr, _AlgoStr, HashHex] -> Version = binary_to_integer(VersionStr), case string_to_codec(CodecStr) of {ok, Codec} -> case macula_content_hasher:hex_decode(HashHex) of {ok, Hash} when byte_size(Hash) =:= 32 -> {ok, Version, Codec, Hash}; _ -> error end; error -> error end; _ -> error end; parse_mcid_string(_) -> error. %% @private manifest_to_encodable(Manifest) -> #{ <<"mcid">> => maps:get(mcid, Manifest), <<"version">> => maps:get(version, Manifest), <<"name">> => maps:get(name, Manifest), <<"size">> => maps:get(size, Manifest), <<"created">> => maps:get(created, Manifest), <<"chunk_size">> => maps:get(chunk_size, Manifest), <<"chunk_count">> => maps:get(chunk_count, Manifest), <<"hash_algorithm">> => atom_to_binary(maps:get(hash_algorithm, Manifest)), <<"root_hash">> => maps:get(root_hash, Manifest), <<"chunks">> => [chunk_info_to_encodable(C) || C <- maps:get(chunks, Manifest)] }. %% @private chunk_info_to_encodable(ChunkInfo) -> #{ <<"index">> => maps:get(index, ChunkInfo), <<"offset">> => maps:get(offset, ChunkInfo), <<"size">> => maps:get(size, ChunkInfo), <<"hash">> => maps:get(hash, ChunkInfo) }. %% @private decode_manifest(Map) when is_map(Map) -> case maps:is_key(<<"mcid">>, Map) of true -> {ok, #{ mcid => maps:get(<<"mcid">>, Map), version => maps:get(<<"version">>, Map, 1), name => maps:get(<<"name">>, Map, <<"unnamed">>), size => maps:get(<<"size">>, Map, 0), created => maps:get(<<"created">>, Map, 0), chunk_size => maps:get(<<"chunk_size">>, Map, 262144), chunk_count => maps:get(<<"chunk_count">>, Map, 0), hash_algorithm => binary_to_existing_atom( maps:get(<<"hash_algorithm">>, Map, <<"blake3">>), utf8), root_hash => maps:get(<<"root_hash">>, Map, <<>>), chunks => [decode_chunk_info(C) || C <- maps:get(<<"chunks">>, Map, [])] }}; false -> {error, invalid_manifest} end; decode_manifest(_) -> {error, invalid_manifest}. %% @private decode_chunk_info(Map) -> #{ index => maps:get(<<"index">>, Map, 0), offset => maps:get(<<"offset">>, Map, 0), size => maps:get(<<"size">>, Map, 0), hash => maps:get(<<"hash">>, Map, <<>>) }. %% @private verify_root_hash(Manifest, Data) -> ChunkSize = maps:get(chunk_size, Manifest), Algorithm = maps:get(hash_algorithm, Manifest), ExpectedRoot = maps:get(root_hash, Manifest), %% Rechunk and compute root {ok, Chunks} = macula_content_chunker:chunk(Data, ChunkSize), ChunkInfos = macula_content_chunker:chunk_info(Chunks, Algorithm), ActualRoot = case ChunkInfos of [] -> macula_content_hasher:hash(Algorithm, <<>>); _ -> macula_content_chunker:merkle_root(ChunkInfos, Algorithm) end, case ActualRoot =:= ExpectedRoot of true -> ok; false -> {error, root_hash_mismatch} end.