Current section

Files

Jump to
erlang_adk src artifacts adk_artifact_core.erl
Raw

src/artifacts/adk_artifact_core.erl

%% @doc Shared validation and bounded-call helpers for artifact adapters.
-module(adk_artifact_core).
-export([
limits/0,
validate_scope/1,
validate_name/1,
validate_put/4,
validate_lookup/3,
validate_delete/3,
validate_page_options/3,
validate_call_options/2,
deadline_expired/1,
artifact_metadata/6,
validate_loaded_metadata/4,
validate_loaded_data/2,
digest/1
]).
-define(MAX_SCOPE_PART_BYTES, 256).
-define(MAX_NAME_BYTES, 1024).
-define(MAX_MIME_TYPE_BYTES, 255).
-define(MAX_METADATA_BYTES, 16384).
-define(MAX_METADATA_ENTRIES, 128).
-define(MAX_METADATA_DEPTH, 8).
-define(MAX_CALL_TIMEOUT_MS, 300000).
-spec limits() -> map().
limits() ->
#{max_scope_part_bytes => ?MAX_SCOPE_PART_BYTES,
max_name_bytes => ?MAX_NAME_BYTES,
max_mime_type_bytes => ?MAX_MIME_TYPE_BYTES,
max_metadata_bytes => ?MAX_METADATA_BYTES,
max_metadata_entries => ?MAX_METADATA_ENTRIES,
max_metadata_depth => ?MAX_METADATA_DEPTH,
max_call_timeout_ms => ?MAX_CALL_TIMEOUT_MS}.
-spec validate_scope(term()) -> ok | {error, term()}.
validate_scope({app, AppName}) ->
validate_scope_part(app_name, AppName);
validate_scope({user, AppName, UserId}) ->
validate_scope_parts([{app_name, AppName}, {user_id, UserId}]);
validate_scope({session, AppName, UserId, SessionId}) ->
validate_scope_parts([{app_name, AppName}, {user_id, UserId},
{session_id, SessionId}]);
validate_scope(_Scope) ->
{error, invalid_scope}.
-spec validate_name(term()) -> ok | {error, term()}.
validate_name(Name)
when is_binary(Name), byte_size(Name) > 0,
byte_size(Name) =< ?MAX_NAME_BYTES ->
Parts = binary:split(Name, <<"/">>, [global]),
ValidParts = lists:all(
fun(<<>>) -> false;
(<<".">>) -> false;
(<<"..">>) -> false;
(Part) -> binary:match(Part, <<0>>) =:= nomatch
end, Parts),
case valid_utf8(Name) andalso ValidParts of
true -> ok;
false -> {error, invalid_name}
end;
validate_name(_Name) ->
{error, invalid_name}.
-spec validate_put(term(), term(), term(), term()) ->
{ok, binary(), map()} | {error, term()}.
validate_put(Scope, Name, Data, Options)
when is_binary(Data), is_map(Options) ->
case {validate_scope(Scope), validate_name(Name),
validate_options(Options)} of
{ok, ok, {ok, MimeType, Metadata}} ->
{ok, MimeType, Metadata};
{{error, _} = Error, _, _} -> Error;
{_, {error, _} = Error, _} -> Error;
{_, _, {error, _} = Error} -> Error
end;
validate_put(_Scope, _Name, Data, _Options) when not is_binary(Data) ->
{error, invalid_data};
validate_put(_Scope, _Name, _Data, _Options) ->
{error, invalid_options}.
-spec validate_lookup(term(), term(), term()) -> ok | {error, term()}.
validate_lookup(Scope, Name, Selector) ->
case {validate_scope(Scope), validate_name(Name),
valid_selector(Selector)} of
{ok, ok, true} -> ok;
{{error, _} = Error, _, _} -> Error;
{_, {error, _} = Error, _} -> Error;
{_, _, false} -> {error, invalid_selector}
end.
-spec validate_delete(term(), term(), term()) -> ok | {error, term()}.
validate_delete(Scope, Name, all) ->
case {validate_scope(Scope), validate_name(Name)} of
{ok, ok} -> ok;
{{error, _} = Error, _} -> Error;
{_, {error, _} = Error} -> Error
end;
validate_delete(Scope, Name, Selector) ->
validate_lookup(Scope, Name, Selector).
-spec validate_page_options(term(), name | version, pos_integer()) ->
{ok, pos_integer(), undefined | binary() | pos_integer()} |
{error, term()}.
validate_page_options(Options, CursorType, MaxLimit) when is_map(Options) ->
Unknown = maps:without([limit, cursor], Options),
Limit = maps:get(limit, Options, erlang:min(100, MaxLimit)),
Cursor = maps:get(cursor, Options, undefined),
case {map_size(Unknown), valid_limit(Limit, MaxLimit),
valid_cursor(CursorType, Cursor)} of
{Size, _, _} when Size > 0 ->
{error, {unknown_options, lists:sort(maps:keys(Unknown))}};
{_, false, _} -> {error, invalid_limit};
{_, _, false} -> {error, invalid_cursor};
{0, true, true} -> {ok, Limit, Cursor}
end;
validate_page_options(_Options, _CursorType, _MaxLimit) ->
{error, invalid_options}.
-spec validate_call_options(term(), pos_integer()) ->
{ok, pos_integer(), integer()} | {error, term()}.
validate_call_options(Options, DefaultTimeout) when is_map(Options) ->
Unknown = maps:without([timeout_ms], Options),
Timeout = maps:get(timeout_ms, Options, DefaultTimeout),
case {map_size(Unknown), is_integer(Timeout) andalso Timeout > 0 andalso
Timeout =< ?MAX_CALL_TIMEOUT_MS} of
{Size, _} when Size > 0 ->
{error, {unknown_call_options,
lists:sort(maps:keys(Unknown))}};
{_, false} -> {error, invalid_timeout_ms};
{0, true} ->
{ok, Timeout, erlang:monotonic_time(millisecond) + Timeout}
end;
validate_call_options(_Options, _DefaultTimeout) ->
{error, invalid_call_options}.
-spec deadline_expired(integer()) -> boolean().
deadline_expired(Deadline) ->
erlang:monotonic_time(millisecond) >= Deadline.
-spec artifact_metadata(adk_artifact_service:scope(), binary(),
pos_integer(), binary(), binary(), map()) -> map().
artifact_metadata(Scope, Name, Version, Data, MimeType, UserMetadata) ->
#{scope => Scope,
name => Name,
version => Version,
mime_type => MimeType,
digest => digest(Data),
size => byte_size(Data),
created_at => erlang:system_time(millisecond),
metadata => UserMetadata}.
-spec validate_loaded_metadata(term(), adk_artifact_service:scope(),
binary(), pos_integer()) ->
{ok, adk_artifact_service:artifact_meta()} | {error, corrupt_artifact}.
validate_loaded_metadata(Metadata, Scope, Name, Version)
when is_map(Metadata) ->
Required = [scope, name, version, mime_type, digest, size,
created_at, metadata],
case lists:sort(maps:keys(Metadata)) =:= lists:sort(Required) andalso
maps:get(scope, Metadata, invalid) =:= Scope andalso
maps:get(name, Metadata, invalid) =:= Name andalso
maps:get(version, Metadata, invalid) =:= Version andalso
valid_mime_type(maps:get(mime_type, Metadata, invalid)) andalso
valid_digest(maps:get(digest, Metadata, invalid)) andalso
valid_nonnegative_integer(maps:get(size, Metadata, invalid)) andalso
valid_nonnegative_integer(maps:get(created_at, Metadata, invalid)) andalso
valid_metadata(maps:get(metadata, Metadata, invalid)) of
true -> {ok, Metadata};
false -> {error, corrupt_artifact}
end;
validate_loaded_metadata(_Metadata, _Scope, _Name, _Version) ->
{error, corrupt_artifact}.
-spec validate_loaded_data(map(), binary()) ->
{ok, map()} | {error, corrupt_artifact}.
validate_loaded_data(Metadata, Data) ->
case byte_size(Data) =:= maps:get(size, Metadata) andalso
digest(Data) =:= maps:get(digest, Metadata) of
true -> {ok, Metadata#{data => Data}};
false -> {error, corrupt_artifact}
end.
-spec digest(binary()) -> binary().
digest(Data) ->
binary:encode_hex(crypto:hash(sha256, Data), lowercase).
validate_scope_parts([]) -> ok;
validate_scope_parts([{Label, Value} | Rest]) ->
case validate_scope_part(Label, Value) of
ok -> validate_scope_parts(Rest);
{error, _} = Error -> Error
end.
validate_scope_part(Label, Value)
when is_binary(Value), byte_size(Value) > 0,
byte_size(Value) =< ?MAX_SCOPE_PART_BYTES ->
case valid_utf8(Value) andalso binary:match(Value, <<0>>) =:= nomatch of
true -> ok;
false -> {error, {invalid_scope_part, Label}}
end;
validate_scope_part(Label, _Value) ->
{error, {invalid_scope_part, Label}}.
validate_options(Options) ->
Unknown = maps:without([mime_type, metadata], Options),
MimeType = maps:get(mime_type, Options,
<<"application/octet-stream">>),
Metadata = maps:get(metadata, Options, #{}),
case {map_size(Unknown), valid_mime_type(MimeType),
valid_metadata(Metadata)} of
{Size, _, _} when Size > 0 ->
{error, {unknown_options, lists:sort(maps:keys(Unknown))}};
{_, false, _} -> {error, invalid_mime_type};
{_, _, false} -> {error, invalid_metadata};
{0, true, true} -> {ok, MimeType, Metadata}
end.
valid_mime_type(MimeType)
when is_binary(MimeType), byte_size(MimeType) > 2,
byte_size(MimeType) =< ?MAX_MIME_TYPE_BYTES ->
valid_utf8(MimeType) andalso
binary:match(MimeType, <<"/">>) =/= nomatch andalso
binary:match(MimeType, <<"\r">>) =:= nomatch andalso
binary:match(MimeType, <<"\n">>) =:= nomatch andalso
binary:match(MimeType, <<0>>) =:= nomatch;
valid_mime_type(_) -> false.
valid_metadata(Metadata) when is_map(Metadata) ->
case metadata_shape(Metadata, 1) of
{ok, Entries} when Entries =< ?MAX_METADATA_ENTRIES ->
byte_size(term_to_binary(Metadata)) =< ?MAX_METADATA_BYTES;
_ -> false
end;
valid_metadata(_) -> false.
metadata_shape(_Value, Depth) when Depth > ?MAX_METADATA_DEPTH -> error;
metadata_shape(Value, _Depth) when is_binary(Value) ->
case valid_utf8(Value) of true -> {ok, 0}; false -> error end;
metadata_shape(Value, _Depth) when is_integer(Value); is_float(Value) ->
{ok, 0};
metadata_shape(true, _Depth) -> {ok, 0};
metadata_shape(false, _Depth) -> {ok, 0};
metadata_shape(null, _Depth) -> {ok, 0};
metadata_shape(Value, Depth) when is_list(Value) ->
metadata_items(Value, Depth + 1, length(Value));
metadata_shape(Value, Depth) when is_map(Value) ->
Pairs = maps:to_list(Value),
case lists:all(fun({Key, _}) -> is_binary(Key) andalso valid_utf8(Key)
end, Pairs) of
true -> metadata_map_items(Pairs, Depth + 1, length(Pairs));
false -> error
end;
metadata_shape(_Value, _Depth) -> error.
metadata_items([], _Depth, Count) -> {ok, Count};
metadata_items([Item | Rest], Depth, Count0) ->
case metadata_shape(Item, Depth) of
{ok, Count} when Count0 + Count =< ?MAX_METADATA_ENTRIES ->
metadata_items(Rest, Depth, Count0 + Count);
_ -> error
end.
metadata_map_items([], _Depth, Count) -> {ok, Count};
metadata_map_items([{_Key, Item} | Rest], Depth, Count0) ->
case metadata_shape(Item, Depth) of
{ok, Count} when Count0 + Count =< ?MAX_METADATA_ENTRIES ->
metadata_map_items(Rest, Depth, Count0 + Count);
_ -> error
end.
valid_utf8(Value) ->
case unicode:characters_to_binary(Value, utf8, utf8) of
Value -> true;
_ -> false
end.
valid_selector(latest) -> true;
valid_selector(Version) -> is_integer(Version) andalso Version > 0.
valid_limit(Limit, MaxLimit) ->
is_integer(Limit) andalso Limit > 0 andalso Limit =< MaxLimit.
valid_cursor(name, undefined) -> true;
valid_cursor(name, Cursor) when is_binary(Cursor) ->
validate_name(Cursor) =:= ok;
valid_cursor(version, undefined) -> true;
valid_cursor(version, Cursor) -> is_integer(Cursor) andalso Cursor > 0;
valid_cursor(_Type, _Cursor) -> false.
valid_digest(Digest) when is_binary(Digest), byte_size(Digest) =:= 64 ->
lists:all(fun(C) -> (C >= $0 andalso C =< $9) orelse
(C >= $a andalso C =< $f)
end, binary_to_list(Digest));
valid_digest(_) -> false.
valid_nonnegative_integer(Value) ->
is_integer(Value) andalso Value >= 0.