Use Result.map, Result.unwrap_or, and Result.ok? where they replace
verbose case statements. Skipped sites where return shapes (3-tuples,
:skipped atoms) prevent clean adoption.
- proto/wire.ex: skip_field varint and length-delimited use Result.map
- organizations.ex: list_organization_ids uses Result.unwrap_or([])
- security/four_oh_four_tracker.ex: count_404s uses Result.unwrap_or(0)
- billing/billing_notifier.ex: at-least-one-success check uses Result.ok?
- topology.ex: two success-counting sites use Result.ok?
- snmp/wireless_client_discovery.ex: walk_or_empty uses Result.unwrap_or(%{})
303 lines
8.1 KiB
Elixir
303 lines
8.1 KiB
Elixir
defmodule Towerops.Proto.Wire do
|
|
@moduledoc """
|
|
Protobuf wire format primitives.
|
|
|
|
Implements varint encoding/decoding, tag parsing, length-delimited fields,
|
|
IEEE 754 doubles, and int64 two's complement encoding.
|
|
"""
|
|
|
|
import Bitwise
|
|
|
|
alias Towerops.Result
|
|
|
|
# Wire type constants
|
|
@wire_varint 0
|
|
@wire_64bit 1
|
|
@wire_length_delimited 2
|
|
@wire_32bit 5
|
|
|
|
@type wire_error ::
|
|
:unexpected_eof
|
|
| :invalid_varint
|
|
| {:invalid_wire_type, integer()}
|
|
| :invalid_tag
|
|
|
|
# --- Varint ---
|
|
|
|
@doc """
|
|
Encode an unsigned integer as a varint.
|
|
|
|
Uses continuation bit encoding: low 7 bits are data, high bit indicates more bytes.
|
|
"""
|
|
@spec encode_varint(non_neg_integer()) :: binary()
|
|
def encode_varint(value), do: encode_varint_loop(value, <<>>)
|
|
|
|
defp encode_varint_loop(value, acc) when value < 128 do
|
|
<<acc::binary, value::8>>
|
|
end
|
|
|
|
defp encode_varint_loop(value, acc) do
|
|
byte = (value &&& 0x7F) ||| 0x80
|
|
encode_varint_loop(value >>> 7, <<acc::binary, byte::8>>)
|
|
end
|
|
|
|
@doc """
|
|
Decode a varint from the front of a binary.
|
|
Returns the value and remaining bytes.
|
|
"""
|
|
@spec decode_varint(binary()) ::
|
|
{:ok, {non_neg_integer(), binary()}} | {:error, :invalid_varint | :unexpected_eof}
|
|
def decode_varint(data), do: decode_varint_loop(data, 0, 0)
|
|
|
|
defp decode_varint_loop(<<byte::8, rest::binary>>, value, shift) do
|
|
v = value ||| (byte &&& 0x7F) <<< shift
|
|
|
|
if (byte &&& 0x80) == 0 do
|
|
{:ok, {v, rest}}
|
|
else
|
|
if shift >= 63 do
|
|
{:error, :invalid_varint}
|
|
else
|
|
decode_varint_loop(rest, v, shift + 7)
|
|
end
|
|
end
|
|
end
|
|
|
|
defp decode_varint_loop(<<>>, _value, _shift), do: {:error, :unexpected_eof}
|
|
|
|
# --- Tags ---
|
|
|
|
@doc """
|
|
Encode a field tag (field_number << 3 | wire_type).
|
|
"""
|
|
@spec encode_tag(pos_integer(), 0..5) :: binary()
|
|
def encode_tag(field_number, wire_type) do
|
|
encode_varint(field_number <<< 3 ||| wire_type)
|
|
end
|
|
|
|
@doc """
|
|
Decode a field tag. Returns (field_number, wire_type, rest).
|
|
"""
|
|
@spec decode_tag(binary()) ::
|
|
{:ok, {pos_integer(), 0..5, binary()}} | {:error, wire_error()}
|
|
def decode_tag(data) do
|
|
case decode_varint(data) do
|
|
{:ok, {tag_value, rest}} ->
|
|
wire_type = tag_value &&& 0x07
|
|
field_number = tag_value >>> 3
|
|
|
|
if field_number > 0 do
|
|
{:ok, {field_number, wire_type, rest}}
|
|
else
|
|
{:error, :invalid_tag}
|
|
end
|
|
|
|
{:error, e} ->
|
|
{:error, e}
|
|
end
|
|
end
|
|
|
|
# --- Length-delimited ---
|
|
|
|
@doc """
|
|
Encode a length-delimited field (length prefix + bytes).
|
|
"""
|
|
@spec encode_bytes(binary()) :: binary()
|
|
def encode_bytes(data) do
|
|
len = byte_size(data)
|
|
<<encode_varint(len)::binary, data::binary>>
|
|
end
|
|
|
|
@doc """
|
|
Decode a length-delimited field. Returns (field_bytes, rest).
|
|
"""
|
|
@spec decode_bytes(binary()) ::
|
|
{:ok, {binary(), binary()}} | {:error, :invalid_varint | :unexpected_eof}
|
|
def decode_bytes(data) do
|
|
case decode_varint(data) do
|
|
{:ok, {len, rest}} ->
|
|
if byte_size(rest) >= len do
|
|
<<field_data::binary-size(len), remaining::binary>> = rest
|
|
{:ok, {field_data, remaining}}
|
|
else
|
|
{:error, :unexpected_eof}
|
|
end
|
|
|
|
{:error, e} ->
|
|
{:error, e}
|
|
end
|
|
end
|
|
|
|
# --- Double (IEEE 754, 64-bit little-endian) ---
|
|
|
|
@doc """
|
|
Encode a float as an 8-byte little-endian IEEE 754 double.
|
|
"""
|
|
@spec encode_double(float()) :: binary()
|
|
def encode_double(value) do
|
|
<<value::float-little-64>>
|
|
end
|
|
|
|
@doc """
|
|
Decode an 8-byte little-endian IEEE 754 double.
|
|
"""
|
|
@spec decode_double(binary()) :: {:ok, {float(), binary()}} | {:error, wire_error()}
|
|
def decode_double(data) when byte_size(data) >= 8 do
|
|
<<value::float-little-64, rest::binary>> = data
|
|
{:ok, {value, rest}}
|
|
end
|
|
|
|
def decode_double(_data), do: {:error, :unexpected_eof}
|
|
|
|
# --- Int64 (signed, two's complement via varint) ---
|
|
|
|
@doc """
|
|
Encode a signed int64 as a varint (two's complement for negatives).
|
|
"""
|
|
@spec encode_int64(integer()) :: nonempty_binary()
|
|
def encode_int64(value) when value < 0 do
|
|
# Two's complement: add 2^64
|
|
unsigned = value + 18_446_744_073_709_551_616
|
|
encode_varint(unsigned)
|
|
end
|
|
|
|
def encode_int64(value), do: encode_varint(value)
|
|
|
|
@doc """
|
|
Convert a decoded varint value to a signed int64.
|
|
Values >= 2^63 are negative in two's complement.
|
|
"""
|
|
@spec decode_int64_value(non_neg_integer()) :: integer()
|
|
def decode_int64_value(value) when value >= 9_223_372_036_854_775_808 do
|
|
value - 18_446_744_073_709_551_616
|
|
end
|
|
|
|
def decode_int64_value(value), do: value
|
|
|
|
# --- Skip unknown fields ---
|
|
|
|
@doc """
|
|
Skip a field of the given wire type. Returns remaining bytes.
|
|
"""
|
|
@spec skip_field(0..5, binary()) :: {:ok, binary()} | {:error, wire_error()}
|
|
def skip_field(@wire_varint, data) do
|
|
Result.map(decode_varint(data), fn {_value, rest} -> rest end)
|
|
end
|
|
|
|
def skip_field(@wire_64bit, data) when byte_size(data) >= 8 do
|
|
<<_skip::binary-size(8), rest::binary>> = data
|
|
{:ok, rest}
|
|
end
|
|
|
|
def skip_field(@wire_64bit, _data), do: {:error, :unexpected_eof}
|
|
|
|
def skip_field(@wire_length_delimited, data) do
|
|
Result.map(decode_bytes(data), fn {_field_data, rest} -> rest end)
|
|
end
|
|
|
|
def skip_field(@wire_32bit, data) when byte_size(data) >= 4 do
|
|
<<_skip::binary-size(4), rest::binary>> = data
|
|
{:ok, rest}
|
|
end
|
|
|
|
def skip_field(@wire_32bit, _data), do: {:error, :unexpected_eof}
|
|
|
|
def skip_field(other, _data), do: {:error, {:invalid_wire_type, other}}
|
|
|
|
# --- Encode helpers for building messages ---
|
|
|
|
@doc """
|
|
Encode a string field (tag + length-delimited). Skip if empty.
|
|
"""
|
|
@spec encode_string_field(iodata(), pos_integer(), String.t()) :: iodata()
|
|
def encode_string_field(builder, _field_number, ""), do: builder
|
|
|
|
def encode_string_field(builder, field_number, value) do
|
|
[
|
|
builder,
|
|
encode_tag(field_number, @wire_length_delimited),
|
|
encode_bytes(value)
|
|
]
|
|
end
|
|
|
|
@doc """
|
|
Encode a uint32/uint64 varint field. Skip if 0.
|
|
"""
|
|
@spec encode_uint_field(iodata(), pos_integer(), non_neg_integer()) :: iodata()
|
|
def encode_uint_field(builder, _field_number, 0), do: builder
|
|
|
|
def encode_uint_field(builder, field_number, value) do
|
|
[
|
|
builder,
|
|
encode_tag(field_number, @wire_varint),
|
|
encode_varint(value)
|
|
]
|
|
end
|
|
|
|
@doc """
|
|
Encode an int64 varint field. Skip if 0.
|
|
"""
|
|
@spec encode_int64_field(iodata(), pos_integer(), integer()) :: iodata()
|
|
def encode_int64_field(builder, _field_number, 0), do: builder
|
|
|
|
def encode_int64_field(builder, field_number, value) do
|
|
[
|
|
builder,
|
|
encode_tag(field_number, @wire_varint),
|
|
encode_int64(value)
|
|
]
|
|
end
|
|
|
|
@doc """
|
|
Encode a bool field. Skip if false.
|
|
"""
|
|
@spec encode_bool_field(iodata(), pos_integer(), boolean()) :: iodata()
|
|
def encode_bool_field(builder, _field_number, false), do: builder
|
|
|
|
def encode_bool_field(builder, field_number, true) do
|
|
[
|
|
builder,
|
|
encode_tag(field_number, @wire_varint),
|
|
encode_varint(1)
|
|
]
|
|
end
|
|
|
|
@doc """
|
|
Encode a double field.
|
|
|
|
Note: In protobuf, zero values are typically omitted, but in OTP 27+
|
|
pattern matching on 0.0 only matches +0.0. We encode all values to avoid this issue.
|
|
"""
|
|
@spec encode_double_field(iodata(), pos_integer(), float()) :: nonempty_maybe_improper_list()
|
|
def encode_double_field(builder, field_number, value) do
|
|
[
|
|
builder,
|
|
encode_tag(field_number, @wire_64bit),
|
|
encode_double(value)
|
|
]
|
|
end
|
|
|
|
@doc """
|
|
Encode a sub-message field (tag + length-delimited). Skip if empty.
|
|
"""
|
|
@spec encode_message_field(iodata(), pos_integer(), binary()) :: iodata()
|
|
def encode_message_field(builder, _field_number, data) when byte_size(data) == 0 do
|
|
builder
|
|
end
|
|
|
|
def encode_message_field(builder, field_number, data) do
|
|
[
|
|
builder,
|
|
encode_tag(field_number, @wire_length_delimited),
|
|
encode_bytes(data)
|
|
]
|
|
end
|
|
|
|
@doc """
|
|
Encode an enum field (as varint). Skip if 0.
|
|
"""
|
|
@spec encode_enum_field(iodata(), pos_integer(), non_neg_integer()) :: iodata()
|
|
def encode_enum_field(builder, field_number, value) do
|
|
encode_uint_field(builder, field_number, value)
|
|
end
|
|
end
|