aprs.me/lib/aprsme/callsign.ex
Graham McIntire a4991c1401
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 2s
docs: callsign naming distinction, test coverage review, handoff updates
- 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
2026-07-26 14:57:42 -05:00

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