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