Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 2s
- Aprsme.Callsign: enhanced @moduledoc with explicit AX.25 vs transport-safe identifier distinction, four validation layers documented - Test coverage review: confirmed no gaps from deleted packet_pipeline_integration_test All ingestion scenarios covered across 5 test files; 2491 passed, 0 failures - Handoff document: marked maintainability #4 (callsign naming), #6 (test coverage) as done
64 lines
2.4 KiB
Elixir
64 lines
2.4 KiB
Elixir
defmodule Aprsme.Callsign do
|
|
@moduledoc """
|
|
Validation and normalization for APRS transport-safe identifiers.
|
|
|
|
## Terminology
|
|
|
|
This module validates **transport-safe APRS identifiers**, not strict amateur
|
|
radio AX.25 callsigns. The distinction is intentional:
|
|
|
|
- **AX.25 callsigns** are short (3-6 chars), format-restricted (e.g. `K5ABC`),
|
|
and used on the RF side by amateur radio operators. The `User` schema uses
|
|
this stricter format for registered user callsigns.
|
|
|
|
- **Transport-safe identifiers** accept a broader character set
|
|
(`[A-Z0-9]+` segments separated by hyphens, up to 20 bytes). This includes
|
|
tactical callsigns, object names, gateway identifiers, and other
|
|
APRS-IS originated labels that appear in the `sender`/`destination` fields
|
|
of packets on the APRS internet stream.
|
|
|
|
This module purposefully does **not** enforce AX.25 format constraints —
|
|
tightening it would reject valid APRS-IS traffic. Future changes to
|
|
validation rules should remain additive (rejecting more at the edges) rather
|
|
than narrowing the accepted format.
|
|
|
|
See also: `AprsmeWeb.Live.Shared.ParamUtils.valid_callsign?/1` for
|
|
user-facing search validation, and `AprsmeWeb.Api.V1.CallsignController`
|
|
for API endpoint validation.
|
|
"""
|
|
|
|
@safe_identifier_regex ~r/^[A-Z0-9]+(?:-[A-Z0-9]+)*$/
|
|
|
|
@spec valid?(term()) :: boolean()
|
|
def valid?(callsign) when is_binary(callsign) do
|
|
normalized = normalize(callsign)
|
|
byte_size(normalized) in 1..20 and Regex.match?(@safe_identifier_regex, normalized)
|
|
end
|
|
|
|
def valid?(_callsign), do: false
|
|
|
|
@spec normalize(term()) :: String.t()
|
|
def normalize(callsign) when is_binary(callsign), do: callsign |> String.trim() |> String.upcase()
|
|
def normalize(_callsign), do: ""
|
|
|
|
@spec matches?(term(), term()) :: boolean()
|
|
def matches?(left, right), do: normalize(left) == normalize(right)
|
|
|
|
@spec extract_base(term()) :: String.t()
|
|
def extract_base(callsign), do: callsign |> extract_parts() |> elem(0)
|
|
|
|
@spec extract_ssid(term()) :: String.t()
|
|
def extract_ssid(callsign), do: callsign |> extract_parts() |> elem(1)
|
|
|
|
@spec extract_parts(term()) :: {String.t(), String.t()}
|
|
def extract_parts(callsign) when is_binary(callsign) do
|
|
normalized = normalize(callsign)
|
|
|
|
case Regex.run(~r/^(.*)-([0-9]+)$/, normalized) do
|
|
[_, base, ssid] -> {base, ssid}
|
|
_ -> {normalized, "0"}
|
|
end
|
|
end
|
|
|
|
def extract_parts(_callsign), do: {"", "0"}
|
|
end
|