defmodule BreakGlass.CIDR do
@moduledoc false
# Private internal module — not part of the public API.
#
# Implements CIDR-based IP address matching for the break-glass IP whitelist.
# Supports both IPv4 (dotted-decimal) and IPv6 addresses and CIDR ranges.
#
# Cross-family comparisons (IPv4 address vs IPv6 CIDR or vice-versa) always
# return false.
#
# Requirements: 3.1, 3.3, 3.4, 3.5, 16.3
import Bitwise
@doc """
Returns `true` if `ip` matches any entry in `allowed_ips`.
Each entry in `allowed_ips` may be an exact IP address string or a CIDR
notation string (e.g. `"10.0.0.0/8"` or `"fd00::/8"`). Exact IP addresses
without a `/` prefix are treated as `/32` (IPv4) or `/128` (IPv6).
"""
@spec check(ip :: String.t(), allowed_ips :: [String.t()]) :: boolean()
def check(ip, allowed_ips) when is_binary(ip) and is_list(allowed_ips) do
Enum.any?(allowed_ips, &cidr_match?(ip, &1))
end
def check(_ip, _allowed_ips), do: false
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
@spec cidr_match?(ip_string :: String.t(), cidr_or_ip_string :: String.t()) :: boolean()
defp cidr_match?(ip_string, cidr_or_ip_string) do
{addr_string, prefix} = parse_cidr_notation(cidr_or_ip_string)
with {:ok, ip_tuple} <- parse_ip(ip_string),
{:ok, net_tuple} <- parse_ip(addr_string),
:same_family <- check_family(ip_tuple, net_tuple),
{:ok, effective_prefix} <- effective_prefix(prefix, ip_bit_size(ip_tuple)) do
match_prefix?(ip_tuple, net_tuple, effective_prefix)
else
_ -> false
end
end
defp parse_cidr_notation(cidr_or_ip_string) do
case String.split(cidr_or_ip_string, "/", parts: 2) do
[addr, prefix_str] ->
case Integer.parse(prefix_str) do
{p, ""} -> {addr, p}
_ -> {cidr_or_ip_string, nil}
end
[addr] ->
{addr, nil}
end
end
defp check_family(ip_tuple, net_tuple) do
if ip_bit_size(ip_tuple) == ip_bit_size(net_tuple), do: :same_family, else: :different_family
end
defp effective_prefix(prefix, bit_size) do
case prefix do
nil -> {:ok, bit_size}
p when p >= 0 and p <= bit_size -> {:ok, p}
_ -> :error
end
end
defp match_prefix?(_ip_tuple, _net_tuple, 0), do: true
defp match_prefix?(ip_tuple, net_tuple, p) do
bits = ip_bit_size(ip_tuple)
mask = compute_mask(bits, p)
(ip_to_int(ip_tuple) &&& mask) == (ip_to_int(net_tuple) &&& mask)
end
defp parse_ip(ip_string) when is_binary(ip_string) do
charlist = String.to_charlist(ip_string)
case :inet.parse_address(charlist) do
{:ok, tuple} -> {:ok, tuple}
{:error, _} -> :error
end
end
# Returns the bit size of an IP address based on tuple arity
# 4-tuple → 32-bit (IPv4); 8-tuple → 128-bit (IPv6)
defp ip_bit_size(tuple) when tuple_size(tuple) == 4, do: 32
defp ip_bit_size(tuple) when tuple_size(tuple) == 8, do: 128
# Compute a bitmask of `bits` total bits with `prefix` leading 1s
defp compute_mask(bits, prefix) do
max_val = (1 <<< bits) - 1
-1 <<< (bits - prefix) &&& max_val
end
# Convert an IPv4 4-tuple to a 32-bit integer
defp ip_to_int({a, b, c, d}) do
a * 16_777_216 + b * 65_536 + c * 256 + d
end
# Convert an IPv6 8-tuple to a 128-bit integer using binary pattern matching
defp ip_to_int({a, b, c, d, e, f, g, h}) do
<<n::128>> = <<a::16, b::16, c::16, d::16, e::16, f::16, g::16, h::16>>
n
end
end