towerops/lib/towerops/proto/wire.ex
Graham McIntire 6c90e8bd4f refactor: adopt Towerops.Result helpers in 8 call sites
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(%{})
2026-04-30 14:43:24 -05:00

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