From 445c932a40f4ff56c9a59082ca49f064377bff45 Mon Sep 17 00:00:00 2001 From: Nick Juliano Date: Sat, 25 Jul 2026 10:21:52 -0400 Subject: [PATCH] fix: preserve lazy Lua links and commitments --- src/core/resolver/hb_cache.erl | 67 +++++++++++++++++++++++++-- src/preloaded/vm/dev_lua.erl | 82 +++++++++++++++++++++++++++------- 2 files changed, 128 insertions(+), 21 deletions(-) diff --git a/src/core/resolver/hb_cache.erl b/src/core/resolver/hb_cache.erl index 59e972a23..51b65c595 100644 --- a/src/core/resolver/hb_cache.erl +++ b/src/core/resolver/hb_cache.erl @@ -710,9 +710,10 @@ read_all_commitments(Msg, Opts) -> true, { CommitmentID, - ensure_all_loaded( + load_commitment( Commitment, - Opts#{ <<"commitment">> => true } + UncommittedID, + Opts ) } }; @@ -866,9 +867,10 @@ prepare_typed_values(Target, RootPath, Subpaths, Values, Store, Opts) -> case do_read_commitment(CommPath, Opts) of {ok, Commitment} -> LoadedCommitment = - ensure_all_loaded( + load_commitment( Commitment, - Opts#{ <<"commitment">> => true } + RootPath, + Opts ), ?event(read_commitment, {found_target_commitment, @@ -962,6 +964,34 @@ prepare_typed_values(Target, RootPath, Subpaths, Values, Store, Opts) -> end end. +%% @doc Fully load a commitment while recovering the legacy representation of +%% an empty `committed' key list. Lua historically decoded that empty list as +%% `#{}`. Its cache ID is also the uncommitted ID of an empty message, so loading +%% the link from a commitment stored at that same root recursively re-entered +%% the root forever. A first-hop link back to the commitment's base therefore +%% represents the empty committed-key list. +load_commitment(Commitment, BasePath, Opts) -> + CommitmentOpts = Opts#{ <<"commitment">> => true }, + case maps:get(<<"committed">>, Commitment, not_found) of + {link, LinkID, LinkOpts = #{ + <<"type">> := <<"link">>, + <<"lazy">> := true + }} -> + LinkReadOpts = + hb_util:deep_merge(CommitmentOpts, LinkOpts, CommitmentOpts), + case do_read_commitment(LinkID, LinkReadOpts) of + {ok, BasePath} -> + ensure_all_loaded( + Commitment#{ <<"committed">> => [] }, + CommitmentOpts + ); + _ -> + ensure_all_loaded(Commitment, CommitmentOpts) + end; + _ -> + ensure_all_loaded(Commitment, CommitmentOpts) + end. + apply_type_to_immediate(Value, #{ <<"type">> := Type }) -> hb_util:decode(Type, Value); apply_type_to_immediate(Value, _LinkOpts) -> @@ -1156,6 +1186,33 @@ test_store_unsigned_empty_message(Store) -> ?event_debug({match_result, MatchRes}), ?assert(MatchRes). +test_store_lua_decoded_empty_commitment(Store) -> + ?event_debug(debug_store_test, {store, Store}), + hb_store:reset(Store), + Opts = #{ <<"store">> => Store }, + SignedEmpty = + hb_message:commit( + #{}, + Opts, + #{ <<"type">> => <<"unsigned">> } + ), + % Lua historically decoded the empty committed-key list as an empty map. + % Recreate that persisted representation to ensure it remains readable. + LuaDecoded = + SignedEmpty#{ + <<"commitments">> => + maps:map( + fun(_ID, Commitment) -> + Commitment#{ <<"committed">> => #{} } + end, + maps:get(<<"commitments">>, SignedEmpty) + ) + }, + {ok, Path} = write(LuaDecoded, Opts), + {ok, Retrieved} = read(Path, Opts), + [Commitment] = maps:values(maps:get(<<"commitments">>, Retrieved)), + ?assertEqual([], maps:get(<<"committed">>, Commitment)). + test_store_unsigned_nested_empty_message(Store) -> ?event_debug(debug_store_test, {store, Store}), hb_store:reset(Store), @@ -1444,6 +1501,8 @@ cache_suite_test_() -> hb_store:generate_test_suite([ {"store unsigned empty message", fun test_store_unsigned_empty_message/1}, + {"store Lua-decoded empty commitment", + fun test_store_lua_decoded_empty_commitment/1}, {"store binary", fun test_store_binary/1}, {"store unsigned nested empty message", fun test_store_unsigned_nested_empty_message/1}, diff --git a/src/preloaded/vm/dev_lua.erl b/src/preloaded/vm/dev_lua.erl index e09c8a289..cefcd2a57 100644 --- a/src/preloaded/vm/dev_lua.erl +++ b/src/preloaded/vm/dev_lua.erl @@ -315,20 +315,18 @@ compute(Key, RawBase, RawReq, Opts) -> Opts#{ <<"hashpath">> => ignore } ), ?event(debug_lua, parameters_found), - % Resolve all hyperstate links - ResolvedParams = hb_cache:ensure_all_loaded(Params, Opts), % Call the VM function with the given arguments. ?event(lua, {calling_lua_func, {function, Function}, - {args, ResolvedParams}, + {args, Params}, {req, Req} } ), process_response( try luerl:call_function_dec( [Function], - encode(ResolvedParams, Opts), + encode(Params, Opts), State ) catch @@ -425,7 +423,11 @@ normalize(Base, _Req, RawOpts) -> %% @doc Decode a Lua result into a HyperBEAM `structured@1.0' message. decode(EncMsg, Opts) -> - hb_message:normalize_commitments(do_decode(EncMsg, Opts), Opts, verify). + hb_message:normalize_commitments( + normalize_decoded_commitments(do_decode(EncMsg, Opts)), + Opts, + verify + ). do_decode(EncMsg, _Opts) when is_list(EncMsg) andalso length(EncMsg) == 0 -> % The value is an empty table, so we assume it is a message rather than % a list. @@ -452,22 +454,52 @@ do_decode(Msg, Opts) when is_map(Msg) -> do_decode(Other, _Opts) -> Other. +%% @doc Restore the list type of commitment key sets after crossing the Lua +%% boundary. Lua has only one table type, so an empty list is decoded as an +%% empty message by `do_decode/2'. HTTPSig commitments define `committed' as a +%% list; leaving an empty value as `#{}` makes it share the empty-message cache +%% ID and can create a self-referential cache entry. +normalize_decoded_commitments(Msg) when is_map(Msg) -> + maps:map( + fun(<<"commitments">>, Commitments) when is_map(Commitments) -> + maps:map( + fun(_ID, Commitment) -> + normalize_decoded_commitment(Commitment) + end, + Commitments + ); + (_Key, Value) -> + normalize_decoded_commitments(Value) + end, + Msg + ); +normalize_decoded_commitments(List) when is_list(List) -> + lists:map(fun normalize_decoded_commitments/1, List); +normalize_decoded_commitments(Value) -> + Value. + +normalize_decoded_commitment(Commitment) when is_map(Commitment) -> + case maps:get(<<"committed">>, Commitment, not_found) of + Committed when is_map(Committed), ?IS_EMPTY_MESSAGE(Committed) -> + Commitment#{ <<"committed">> => [] }; + _ -> + Commitment + end; +normalize_decoded_commitment(Commitment) -> + Commitment. + %% @doc Encode a HyperBEAM `structured@1.0' message into a Lua term. encode(Map, Opts) -> - hb_message:normalize_commitments(do_encode(Map, Opts), Opts). + do_encode(Map, Opts). do_encode(Map, Opts) when is_map(Map) -> - hb_cache:ensure_all_loaded( - case hb_util:is_ordered_list(Map, Opts) of - true -> do_encode(hb_util:message_to_ordered_list(Map), Opts); - false -> maps:to_list(maps:map(fun(_, V) -> do_encode(V, Opts) end, Map)) - end, - Opts - ); + case hb_util:is_ordered_list(Map, Opts) of + true -> do_encode(hb_util:message_to_ordered_list(Map), Opts); + false -> maps:to_list(maps:map(fun(_, V) -> do_encode(V, Opts) end, Map)) + end; do_encode(List, Opts) when is_list(List) -> - hb_cache:ensure_all_loaded( - lists:map(fun(V) -> do_encode(V, Opts) end, List), - Opts - ); + lists:map(fun(V) -> do_encode(V, Opts) end, List); +do_encode(Link, Opts) when ?IS_LINK(Link) -> + do_encode(hb_cache:ensure_all_loaded(Link, Opts), Opts); do_encode(Atom, _Opts) when is_atom(Atom) and (Atom /= false) and (Atom /= true)-> hb_util:bin(Atom); do_encode(Other, _Opts) -> @@ -525,6 +557,22 @@ simple_invocation_test() -> }, ?assertEqual(2, hb_ao:get(<<"assoctable/b">>, Base, #{})). +empty_commitment_key_list_decode_test() -> + CommitmentID = <<"test-commitment">>, + Decoded = normalize_decoded_commitments(#{ + <<"commitments">> => #{ + CommitmentID => #{ <<"committed">> => #{} } + } + }), + ?assertEqual( + [], + hb_ao:get( + <<"commitments/test-commitment/committed">>, + Decoded, + #{} + ) + ). + post_invocation_message_validation_test() -> {ok, Script} = file:read_file("test/test.lua"), Opts = #{ <<"priv-wallet">> => hb:wallet() },