towerops/src/towerops_proto_decode_ffi.erl
Graham McIntire e54ee75513 Rewrite protobuf encoding/decoding in pure Gleam with integrated validation (#104)
Replace the protobuf hex package (agent.pb.ex) and hand-written validator
(validator.ex) with a pure Gleam implementation: wire format primitives,
all 23 message types, encode/decode functions, and validation merged into
decode. Thin Elixir wrappers in agent.ex preserve the existing struct API.

- Wire format: varint, length-delimited, double (IEEE 754 via FFI), tag encode/decode
- Types: all proto3 message types as Gleam custom types with enums and oneof
- Decode: field-level parsing with integrated validation (UUID, IP, hostname, limits)
- Encode: proto3 default omission, bytes_tree concatenation, map/repeated fields
- Wrappers: Elixir structs with encode/decode delegating to Gleam, enum atom conversion
- Remove {:protobuf, "~> 0.12"} dependency

Reviewed-on: graham/towerops-web#104
2026-03-21 16:08:40 -05:00

79 lines
2.7 KiB
Erlang

-module(towerops_proto_decode_ffi).
-export([bits_to_string/1, is_valid_uuid/1, is_valid_version/1,
is_valid_hostname/1, parse_ip_address/1, int_to_string/1,
int_to_float/1, float_gte/2, float_lte/2]).
%% Convert binary to string (identity on BEAM, but validates UTF-8).
-spec bits_to_string(binary()) -> {ok, binary()} | {error, nil}.
bits_to_string(Data) when is_binary(Data) ->
case unicode:characters_to_binary(Data, utf8) of
Bin when is_binary(Bin) -> {ok, Bin};
_ -> {error, nil}
end.
%% Check if a string is a valid UUID (lowercase or uppercase hex).
-spec is_valid_uuid(binary()) -> boolean().
is_valid_uuid(<<A:8/binary, $-, B:4/binary, $-, C:4/binary, $-, D:4/binary, $-, E:12/binary>>) ->
is_hex(A) andalso is_hex(B) andalso is_hex(C) andalso is_hex(D) andalso is_hex(E);
is_valid_uuid(_) ->
false.
is_hex(<<>>) -> true;
is_hex(<<C, Rest/binary>>) when
(C >= $0 andalso C =< $9) orelse
(C >= $a andalso C =< $f) orelse
(C >= $A andalso C =< $F) ->
is_hex(Rest);
is_hex(_) -> false.
%% Check if a version string is valid.
%% Accepts: semver (1.2.3, 1.2.3-rc.1), RFC 3339 timestamps,
%% git short SHA (7-40 hex chars), short identifiers like "dev".
-spec is_valid_version(binary()) -> boolean().
is_valid_version(Version) ->
%% Try patterns in order
case re:run(Version, <<"^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.\\-]+)?$">>) of
{match, _} -> true;
nomatch ->
case re:run(Version, <<"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$">>) of
{match, _} -> true;
nomatch ->
case re:run(Version, <<"^[a-zA-Z0-9]{1,40}$">>) of
{match, _} -> true;
nomatch -> false
end
end
end.
%% Check if a hostname is valid RFC 1123.
-spec is_valid_hostname(binary()) -> boolean().
is_valid_hostname(Hostname) ->
case re:run(Hostname, <<"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$">>) of
{match, _} -> true;
nomatch -> false
end.
%% Parse an IP address (IPv4 or IPv6).
-spec parse_ip_address(binary()) -> {ok, nil} | {error, nil}.
parse_ip_address(Ip) ->
case inet:parse_address(binary_to_list(Ip)) of
{ok, _} -> {ok, nil};
{error, _} -> {error, nil}
end.
%% Convert integer to string.
-spec int_to_string(integer()) -> binary().
int_to_string(I) ->
integer_to_binary(I).
%% Convert integer to float.
-spec int_to_float(integer()) -> float().
int_to_float(I) ->
float(I).
%% Float comparison helpers.
-spec float_gte(float(), float()) -> boolean().
float_gte(A, B) -> A >= B.
-spec float_lte(float(), float()) -> boolean().
float_lte(A, B) -> A =< B.