aprs.me/lib/aprsme/encoding.ex
Graham McIntire 7b488e9fd7
Fix code review findings batch 2: dead code, encoding, correctness
- Delete dead modules: Archiver, PacketPipelineSetup, SystemMonitor
- Encoding: fast-path for clean ASCII, fix latin1 C1 control chars (128-159)
- SpatialPubSub: fix date line grid cell wrapping
- QueryBuilder: use indexed has_weather column instead of 10-way OR
- PacketProcessor/HistoricalLoader: wire up heat map via DisplayManager
- MapLive: add PacketBatcher crash recovery with Process.monitor
- MapLive: fix time display re-render by touching assign
- DeviceCache: remove unreachable outer try/rescue
- Remove dead stubs: RateLimiterWrapper count/reset, DeviceIdentification enqueue_refresh_job
- Simplify Application.pubsub_config/0
- Add tests for SpatialPubSub, PacketBatcher, encoding, weather_only
2026-02-20 07:29:27 -06:00

214 lines
5.8 KiB
Elixir

defmodule Aprsme.Encoding do
@moduledoc """
Encoding utilities for handling APRS packet data.
Provides functions for sanitizing strings, converting encodings,
and validating data.
"""
@doc """
Sanitizes a binary to ensure it can be safely JSON encoded.
Handles latin1 conversion and removes control characters.
"""
@spec sanitize_string(binary()) :: binary()
def sanitize_string(input) when is_binary(input) do
input
|> try_utf8_conversion()
|> clean_control_characters()
end
@spec sanitize_string(any()) :: binary()
def sanitize_string(_), do: ""
@doc """
Type-safe float conversion with validation
"""
@spec to_float_safe(binary()) :: {:ok, float()} | nil
def to_float_safe(value) when is_binary(value) do
sanitized =
value
|> String.trim()
# Reasonable max length for a number
|> String.slice(0, 30)
case Float.parse(sanitized) do
{f, _} when f > -9.0e15 and f < 9.0e15 ->
{:ok, f}
_ ->
nil
end
end
@spec to_float_safe(any()) :: nil
def to_float_safe(_), do: nil
@doc """
Convert binary to hex string
"""
@spec to_hex(binary()) :: binary()
def to_hex(input) when is_binary(input) do
Base.encode16(input)
end
@spec to_hex(any()) :: binary()
def to_hex(_), do: ""
@doc """
Check if a value looks like it has weather data
"""
@spec has_weather_data(any(), any(), any(), any()) :: boolean()
def has_weather_data(temperature, humidity, wind_speed, pressure) do
not is_nil(temperature) or
not is_nil(humidity) or
not is_nil(wind_speed) or
not is_nil(pressure)
end
@doc """
Get encoding information about a binary
"""
@spec encoding_info(binary()) :: %{
valid_utf8: boolean(),
byte_count: non_neg_integer(),
char_count: non_neg_integer() | nil,
invalid_at: non_neg_integer() | nil
}
def encoding_info(input) when is_binary(input) do
byte_count = byte_size(input)
if String.valid?(input) do
%{
valid_utf8: true,
byte_count: byte_count,
char_count: String.length(input),
invalid_at: nil
}
else
invalid_pos = find_invalid_byte_position(input)
%{
valid_utf8: false,
byte_count: byte_count,
char_count: nil,
invalid_at: invalid_pos
}
end
end
@spec encoding_info(any()) :: %{
valid_utf8: boolean(),
byte_count: non_neg_integer(),
char_count: nil,
invalid_at: nil
}
def encoding_info(_) do
%{
valid_utf8: false,
byte_count: 0,
char_count: nil,
invalid_at: nil
}
end
# Private functions
@spec try_utf8_conversion(binary()) :: binary()
defp try_utf8_conversion(input) do
if String.valid?(input) do
input
else
# Try latin1 to UTF-8 conversion
latin1_to_utf8(input)
end
end
@spec latin1_to_utf8(binary()) :: binary()
defp latin1_to_utf8(input) do
input
|> :binary.bin_to_list()
|> Enum.map(&latin1_char_to_utf8/1)
|> IO.iodata_to_binary()
rescue
_ -> ""
end
@spec latin1_char_to_utf8(0..255) :: binary()
defp latin1_char_to_utf8(byte) when byte <= 127 do
<<byte>>
end
defp latin1_char_to_utf8(byte) when byte >= 128 and byte <= 159 do
# C1 control range (128-159) has no useful Latin1 characters.
# Replace with Unicode replacement character instead of converting
# to C1 control codepoints that would just get stripped later.
<<0xEF, 0xBF, 0xBD>>
end
defp latin1_char_to_utf8(byte) do
# For Latin1, values 160-255 map to Unicode U+00A0 to U+00FF
# In UTF-8, these become 2-byte sequences: 110xxxxx 10xxxxxx
<<0xC0 + div(byte, 64), 0x80 + rem(byte, 64)>>
end
@spec clean_control_characters(binary()) :: binary()
defp clean_control_characters(s) do
if all_bytes_clean?(s) do
String.trim(s)
else
s
|> String.graphemes()
|> Enum.filter(&valid_grapheme?/1)
|> Enum.join()
|> String.trim()
end
end
# Fast-path check: returns true when every byte is safe ASCII
# (printable chars 32-126, plus tab/newline/CR). Any byte >= 128
# or any control character triggers the slow grapheme-based path.
@spec all_bytes_clean?(binary()) :: boolean()
defp all_bytes_clean?(<<>>), do: true
defp all_bytes_clean?(<<9, rest::binary>>), do: all_bytes_clean?(rest)
defp all_bytes_clean?(<<10, rest::binary>>), do: all_bytes_clean?(rest)
defp all_bytes_clean?(<<13, rest::binary>>), do: all_bytes_clean?(rest)
defp all_bytes_clean?(<<byte, rest::binary>>) when byte >= 32 and byte <= 126, do: all_bytes_clean?(rest)
defp all_bytes_clean?(_), do: false
@spec valid_grapheme?(String.grapheme()) :: boolean()
defp valid_grapheme?(grapheme) do
case String.to_charlist(grapheme) do
[cp] -> valid_codepoint?(cp)
_ -> true
end
end
# Allow tab (0x09), newline (0x0A), carriage return (0x0D)
# Remove other control characters
defp valid_codepoint?(9), do: true
defp valid_codepoint?(10), do: true
defp valid_codepoint?(13), do: true
defp valid_codepoint?(127), do: false
defp valid_codepoint?(c) when c >= 0 and c <= 31, do: false
defp valid_codepoint?(c) when c >= 128 and c <= 159, do: false
defp valid_codepoint?(_), do: true
@spec find_invalid_byte_position(binary()) :: non_neg_integer() | nil
defp find_invalid_byte_position(input) do
input
|> :binary.bin_to_list()
|> Enum.with_index()
|> Enum.find_value(fn {byte, index} ->
if byte > 127 and not valid_utf8_continuation?(input, index) do
index
end
end)
end
@spec valid_utf8_continuation?(binary(), non_neg_integer()) :: boolean()
defp valid_utf8_continuation?(binary, pos) do
<<_::binary-size(pos), _char::utf8, _::binary>> = binary
true
rescue
_ -> false
end
end