Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
420db99
impr: `is-admissible` takes `http-reference` and preserves `device` +…
samcamwilliams Apr 13, 2026
58488cc
fix: tests; trace print
samcamwilliams Apr 13, 2026
39ba1ad
wip: multi-node support in `hb_store_remote_node`
samcamwilliams Apr 13, 2026
6688654
fix: multi request test
Lucifer0x17 Apr 14, 2026
0fa1639
chore: no need to turn off force message
Lucifer0x17 Apr 14, 2026
5715cb6
impr: accept multirequest directives; tidy logic
samcamwilliams Apr 14, 2026
425d4bc
impr: remote store validity enforcement tests
samcamwilliams Apr 14, 2026
092bdf6
impr: `~hook@1.0/on` can trigger on deeply nested paths
samcamwilliams Apr 14, 2026
c8f9b74
wip: avoid `ao-types` encoding `status` as integer
samcamwilliams Apr 14, 2026
24cf6ad
wip: test setup
samcamwilliams Apr 14, 2026
6ced7f8
fix: always strip to only committed before commit/verify
samcamwilliams Apr 14, 2026
e601c0f
fix: `hb_store_remote_node` admissible response hook + test
samcamwilliams Apr 14, 2026
fa296d8
fix: ensure that read target is part of default HTTP `vary`ing scheme
samcamwilliams Apr 16, 2026
f3e0b11
fix: only enforce membership of a read path from a remote when it is …
samcamwilliams Apr 16, 2026
f48dca1
chore: add real-world Arweave.net `hb_store_remote_node` tests
samcamwilliams Apr 16, 2026
4099a50
fix: writing `ID` and `data/ID` paths; verify all commitments on non-…
samcamwilliams Apr 16, 2026
bb7521e
fix: symmetrical handling of `status` encoding
samcamwilliams Apr 17, 2026
29c1908
fix: skip SNP NIF call when the mocked report path is enabled
samcamwilliams Apr 17, 2026
f973947
fix: bail from `ensure_started/1' cleanly when the sidecar dir is absent
samcamwilliams Apr 17, 2026
333a94b
fix: uncommit `test_process/1' base before merging the execution stack
samcamwilliams Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 78 additions & 1 deletion src/dev_cache.erl
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,87 @@
%%% supports writing messages to the store, if the node message has the
%%% writer's address in its `cache_writers' key.
-module(dev_cache).
-export([read/3, write/3, link/3, read_from_cache/2]).
-export([read/3, write/3, link/3, read_from_cache/2, expected_response/3]).
-include("include/hb.hrl").
-include_lib("eunit/include/eunit.hrl").

%% @doc An `is-admissible`-compliant key that verifies a `~cache@1.0/read`
%% response contains the message we expect, and that it is valid. Additionally,
%% if the `http-reference` key is set, we execute the `on/cache-valid-response`
%% hook.
expected_response(Base, Req, Opts) ->
maybe
{ok, Response} ?= hb_maps:find(<<"body">>, Req, Opts),
{ok, Expected} ?= hb_maps:find(<<"expected">>, Base, Opts),
true ?= check_response_matches_expected(Response, Expected, Opts),
dev_hook:on(
[<<"~cache@1.0">>, <<"admissible-response">>],
Response,
Opts
),
{ok, true}
else Reason ->
?event(debug_admissible, {expected_response_error, Reason}),
{ok, false}
end.

%% @doc Verify that a response from a remote cache matches the expected ID.
%% There are three cases:
%% 1. The response is a raw binary (e.g., a direct content-addressed read
%% of a data blob written with `hb_cache:write/2'): the binary's SHA-256
%% hash must match `Expected'.
%% 2. The response is a structured message with commitments, and `Expected'
%% is a commitment ID (the common case for data items): `Expected' must
%% be among the commitment IDs and the corresponding commitment must
%% verify.
%% 3. The response is a structured message but `Expected' is a path rather
%% than a commitment ID (e.g. `~scheduler@1.0/assignments/<ID>/<SLOT>'):
%% we verify all committer-attributed commitments on the response.
check_response_matches_expected(Response, Expected, _Opts) when is_binary(Response) ->
% Accept either a bare ID or a `data/<ID>' path (the convention used by
% `hb_cache:write/2' for content-addressed binary blobs). In both cases,
% the blob's SHA-256 hash must match the expected ID.
BinID =
case binary:split(Expected, <<"/">>) of
[<<"data">>, ID] -> ID;
_ -> Expected
end,
case ?IS_ID(BinID)
andalso hb_util:human_id(hb_crypto:sha256(Response)) =:= BinID of
true -> true;
false -> binary_hash_mismatch
end;
check_response_matches_expected(Response, Expected, Opts) ->
maybe
{ok, Commitments} ?= hb_maps:find(<<"commitments">>, Response, Opts),
CommIDs = maps:keys(Commitments),
?event(debug_admissible,
{expected_response,
{response, Response},
{expected, Expected},
{commitments, CommIDs}
}
),
true ?=
(not ?IS_ID(Expected)
orelse lists:member(Expected, CommIDs))
orelse expected_id_not_found,
{ok, OnlyCommitted} = hb_message:with_only_committed(Response, Opts),
% For ID-based reads, verify the specific commitment that claims to be
% the requested ID. For non-ID reads (path-based reads), the `Expected'
% value is not itself a commitment ID, so we instead verify all
% committer-attributed commitments on the response.
VerifyReq =
case ?IS_ID(Expected) of
true -> #{ <<"commitment-ids">> => [Expected] };
false -> #{ <<"committers">> => <<"all">> }
end,
true ?=
hb_message:verify(OnlyCommitted, VerifyReq, Opts)
orelse invalid_commitment,
true
end.

%% @doc Read data from the cache.
%% Retrieves data corresponding to a key from a local store.
%% The key is extracted from the incoming message under &lt;&lt;"target"&gt;&gt;.
Expand Down
26 changes: 26 additions & 0 deletions src/dev_codec_httpsig.erl
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,32 @@ validate_large_message_from_http_test() ->
?assert(hb_message:verify(OnlyCommitted, all, Opts)),
?event({msg_with_only_committed_verifies_hmac, <<"hmac-sha256">>}).

%% @doc Ensure that a signed response that contains both `status' and another
%% top-level locally-typed key (e.g. an integer) round-trips through HTTP and
%% verifies on the receiving side. This exercises the case where `ao-types'
%% must agree between signer and verifier for multiple locally-typed fields.
validate_sibling_typed_key_over_http_test() ->
Node = hb_http_server:start_node(Opts = #{
force_signed => true,
commitment_device => <<"httpsig@1.0">>,
% Top-level locally-typed siblings to `status' in the signed response:
% one integer that sorts before `status' alphabetically, one that sorts
% after, and an atom. All become entries in `ao-types' alongside
% `status'.
<<"alpha-count">> => 7,
<<"test-count">> => 42,
<<"zebra-flag">> => true
}),
{ok, Res} = hb_http:get(Node, <<"/~meta@1.0/info">>, Opts),
?event({received_with_sibling_typed, Res}),
?assertEqual(7, hb_ao:get(<<"alpha-count">>, Res, undefined, Opts)),
?assertEqual(42, hb_ao:get(<<"test-count">>, Res, undefined, Opts)),
?assertEqual(true, hb_ao:get(<<"zebra-flag">>, Res, undefined, Opts)),
Signers = hb_message:signers(Res, Opts),
?assert(length(Signers) == 1),
?assert(hb_message:verify(Res, Signers, Opts)),
?assert(hb_message:verify(Res, all, Opts)).

committed_id_test() ->
Msg = #{ <<"basic">> => <<"value">> },
Opts = #{ priv_wallet => hb:wallet() },
Expand Down
12 changes: 12 additions & 0 deletions src/dev_genesis_wasm.erl
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,22 @@ ensure_started(Opts) ->
IsRunning = is_genesis_wasm_server_running(Opts),
IsCompiled = hb_features:genesis_wasm(),
GenWASMProc = is_pid(hb_name:lookup(<<"genesis-wasm@1.0">>)),
ServerDirExists = filelib:is_dir(GenesisWasmServerDir),
case IsRunning orelse (IsCompiled andalso GenWASMProc) of
true ->
% If it is, do nothing.
true;
false when not ServerDirExists ->
% The sidecar isn't present on disk (e.g. a development host that
% hasn't fetched the `genesis-wasm-server' Node subproject). Report
% as unavailable rather than spawning an open_port that will die
% with `enoent' and leaving the caller hanging in the start poll.
?event(
warning,
{ensure_started, genesis_wasm_server_missing,
GenesisWasmServerDir}
),
false;
false ->
% The device is not running, so we need to start it.
PID =
Expand Down
8 changes: 3 additions & 5 deletions src/dev_hook.erl
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ find(HookName, Opts) ->
find(#{}, #{ <<"target">> => <<"body">>, <<"body">> => HookName }, Opts).
find(_Base, Req, Opts) ->
HookName = maps:get(maps:get(<<"target">>, Req, <<"body">>), Req),
case maps:get(HookName, hb_opts:get(on, #{}, Opts), []) of
case hb_util:deep_get(HookName, hb_opts:get(on, #{}, Opts), [], Opts) of
Handler when is_map(Handler) ->
case hb_util:is_ordered_list(Handler, Opts) of
true ->
Expand Down Expand Up @@ -164,10 +164,8 @@ execute_handler(HookName, Handler, Req, Opts) ->
% committed before execution.
BaseReq =
Req#{
<<"path">> =>
hb_maps:get(<<"path">>, Handler, HookName, Opts),
<<"method">> =>
hb_maps:get(<<"method">>, Handler, <<"GET">>, Opts)
<<"path">> => hb_maps:get(<<"path">>, Handler, HookName, Opts),
<<"method">> => hb_maps:get(<<"method">>, Handler, <<"GET">>, Opts)
},
CommitReqBin =
hb_util:bin(
Expand Down
6 changes: 4 additions & 2 deletions src/dev_message.erl
Original file line number Diff line number Diff line change
Expand Up @@ -271,9 +271,10 @@ commit(Self, Req, Opts) ->
CommitOpts
),
% Encode to a TABM
{ok, OnlyCommitted} = hb_message:with_only_committed(Base, Opts),
Loaded =
ensure_commitments_loaded(
hb_message:convert(Base, tabm, CommitOpts),
hb_message:convert(OnlyCommitted, tabm, CommitOpts),
Opts
),
{ok, Committed} =
Expand All @@ -297,10 +298,11 @@ commit(Self, Req, Opts) ->
verify(Self, Req, Opts) ->
% Get the target message of the verification request.
{ok, RawBase} = hb_message:find_target(Self, Req, Opts),
{ok, OnlyCommitted} = hb_message:with_only_committed(RawBase, Opts),
Base =
hb_message:convert(
ensure_commitments_loaded(
RawBase,
OnlyCommitted,
Opts
),
tabm,
Expand Down
12 changes: 10 additions & 2 deletions src/dev_process_test_vectors.erl
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,21 @@ test_process() ->
test_process(#{}).
test_process(Opts) ->
Wallet = hb:wallet(),
% Strip the base commitment before merging in the execution stack. A
% re-commit over a message that still carries a prior commitment will
% replicate that commitment's `committed' key list verbatim (see the
% "stacked commitments" branch of `keys_to_commit/3' in
% `dev_codec_httpsig'), leaving the newly-added `execution-device'
% and `device-stack' keys out of the signed set — and therefore out
% of the `with_only_committed' view used in compute. The
% `wasm_process'/`aos_process' helpers already follow this pattern.
hb_message:commit(
hb_maps:merge(
base_process(Opts),
hb_message:uncommitted(base_process(Opts), Opts),
#{
<<"execution-device">> => <<"stack@1.0">>,
<<"device-stack">> => [<<"test-device@1.0">>, <<"test-device@1.0">>]
},
},
Opts
),
Opts#{ priv_wallet => Wallet }
Expand Down
13 changes: 3 additions & 10 deletions src/dev_query.erl
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,9 @@ graphql(Req, Base, Opts) ->
%% @doc Return whether a GraphQL esponse in a message has transaction results.
%% This key is used in HB's gateway client multirequest configuration to
%% determine if the response from the node should be considered admissible.
has_results(Base, Req, Opts) ->
JSON =
hb_ao:get_first(
[
{{as, <<"message@1.0">>, Base}, <<"body">>},
{{as, <<"message@1.0">>, Req}, <<"body">>}
],
<<"{}">>,
Opts
),
has_results(_Base, RawReq, Opts) ->
Req = hb_maps:get(<<"body">>, RawReq, #{}, Opts),
JSON = hb_maps:get(<<"body">>, Req, <<>>, Opts),
Decoded = hb_json:decode(JSON),
?event(debug_multi, {has_results, {decoded_json, Decoded}}),
case Decoded of
Expand Down
29 changes: 15 additions & 14 deletions src/dev_scheduler.erl
Original file line number Diff line number Diff line change
Expand Up @@ -1533,12 +1533,14 @@ redirect_from_graphql_test_() ->
{timeout, 60, fun redirect_from_graphql/0}.
redirect_from_graphql() ->
start(),
TestStore = hb_test_utils:test_store(),
Opts =
#{ store =>
[
#{ <<"store-module">> => hb_store_fs, <<"name">> => <<"cache-mainnet">> },
#{ <<"store-module">> => hb_store_gateway, <<"store">> => [] }
]
#{
store =>
[
TestStore,
#{ <<"store-module">> => hb_store_gateway }
]
},
{ok, Msg} = hb_cache:read(<<"0syT13r0s0tgPmIed95bJnuSqaD29HQNN8D3ElLSrsc">>, Opts),
?assertMatch(
Expand Down Expand Up @@ -1603,14 +1605,12 @@ http_init() -> http_init(#{}).
http_init(Opts) ->
start(),
Wallet = ar_wallet:new(),
TestStore = hb_test_utils:test_store(),
ExtendedOpts = Opts#{
priv_wallet => Wallet,
store => [
#{
<<"store-module">> => hb_store_volatile,
<<"name">> => <<"cache-TEST/volatile">>
},
#{ <<"store-module">> => hb_store_gateway, <<"store">> => [] }
TestStore,
#{ <<"store-module">> => hb_store_gateway }
]
},
Node = hb_http_server:start_node(ExtendedOpts),
Expand Down Expand Up @@ -1663,17 +1663,18 @@ http_get_schedule(N, PMsg, From, To, Format) ->
http_get_schedule_redirect_test_() ->
{timeout, 60, fun http_get_schedule_redirect/0}.
http_get_schedule_redirect() ->
start(),
TestStore = hb_test_utils:test_store(),
Opts =
#{
store =>
[
#{ <<"store-module">> => hb_store_fs, <<"name">> => <<"cache-mainnet">> },
#{ <<"store-module">> => hb_store_gateway, <<"opts">> => #{} }
TestStore,
#{ <<"store-module">> => hb_store_gateway }
],
scheduler_follow_redirects => false
scheduler_follow_redirects => false
},
{N, _Wallet} = http_init(Opts),
start(),
ProcID = <<"0syT13r0s0tgPmIed95bJnuSqaD29HQNN8D3ElLSrsc">>,
Res = hb_http:get(N, <<"/", ProcID/binary, "/schedule">>, Opts),
?assertMatch({ok, #{ <<"location">> := Location }} when is_binary(Location), Res).
Expand Down
22 changes: 17 additions & 5 deletions src/dev_snp.erl
Original file line number Diff line number Diff line change
Expand Up @@ -439,11 +439,23 @@ extract_measurement_args(Msg, NodeOpts) ->
-spec verify_report_integrity(ReportJSON :: binary()) ->
{ok, true} | {error, report_signature_invalid}.
verify_report_integrity(ReportJSON) ->
{ok, ReportIsValid} = dev_snp_nif:verify_signature(ReportJSON),
?event({report_is_valid, ReportIsValid}),
case ReportIsValid of
true -> {ok, true};
false -> {error, report_signature_invalid}
case get(mock_snp_nif_enabled) of
true ->
% The test harness has installed a mock report; skip the NIF call
% (which will panic on hosts without AMD SEV-SNP hardware).
{ok, true};
_ ->
case dev_snp_nif:verify_signature(ReportJSON) of
{ok, ReportIsValid} ->
?event({report_is_valid, ReportIsValid}),
case ReportIsValid of
true -> {ok, true};
false -> {error, report_signature_invalid}
end;
{error, Reason} ->
?event({report_integrity_check_failed, Reason}),
{error, report_signature_invalid}
end
end.

%% @doc Check if the node's debug policy is enabled.
Expand Down
52 changes: 52 additions & 0 deletions src/dev_test.erl
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
-export([info/3]).
-export([info/1, test_func/1, compute/3, init/3, restore/3, snapshot/3, mul/2]).
-export([mangle/3, update_state/3, increment_counter/3, delay/3]).
-export([log_request/3, logs/3]).
-export([index/3, postprocess/3, load/3]).
-include_lib("eunit/include/eunit.hrl").
-include("include/hb.hrl").
Expand Down Expand Up @@ -209,6 +210,57 @@ mangle(Base, _Req, Opts) ->
end
end.

%%% Logged messages functionality

-define(LOG_PREFIX, "~test-device@1.0").

%% @doc Determines the store to use for logging requests.
determine_log_store(Base, _Req, Opts) ->
hb_maps:get(
<<"store">>,
Base,
hb_opts:get(store, no_viable_store, Opts),
Opts
).

%% @doc Write a pseudo-path to the store linking the received message to the
%% millisecond timestamp that the key was invoked.
log_request(Base, Req, Opts) ->
Timestamp = hb_util:bin(erlang:system_time(millisecond)),
Store = determine_log_store(Base, Req, Opts),
{ok, ReqID} = hb_cache:write(Req, Opts#{ store => Store }),
hb_store:make_link(
Store,
ReqID,
<<?LOG_PREFIX, "/request-", Timestamp/binary>>
),
{ok, ReqID}.

%% @doc Return all logs of requests to the device.
logs(Base, Req, Opts) ->
Store = determine_log_store(Base, Req, Opts),
LogOpts = Opts#{ store => Store },
case hb_store:list(Store, <<?LOG_PREFIX>>) of
{ok, LogKeys} ->
Logs =
maps:from_list(
lists:map(
fun(K = <<"request-", TimeBin/binary>>) ->
{ok, Request} =
hb_cache:read(
<<"~test-device@1.0/", K/binary>>,
LogOpts
),
{hb_util:int(TimeBin), Request}
end,
LogKeys
)
),
{ok, Logs};
_ ->
{error, <<"No logs found.">>}
end.

%%% Tests

%% @doc Tests the resolution of a default function.
Expand Down
Loading