valiant-0.1.0.0: src/Valiant/Binary/Inet.hs
{-# OPTIONS_GHC -Wno-orphans #-}
-- | Binary codec for PostgreSQL @inet@ and @cidr@ types.
--
-- PG binary format (both inet and cidr):
--
-- 1 byte: address family (2 = IPv4, 3 = IPv6)
-- 1 byte: prefix length (netmask bits: 0–32 for v4, 0–128 for v6)
-- 1 byte: is_cidr flag (0 = inet, 1 = cidr)
-- 1 byte: address length (4 for IPv4, 16 for IPv6)
-- N bytes: address bytes (network byte order)
--
-- Total: 8 bytes for IPv4, 20 bytes for IPv6.
module Valiant.Binary.Inet
( PgInet (..)
, ipv4
, ipv4Host
, ipv6
, ipv6Host
, inetToText
) where
import Control.DeepSeq (NFData)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Builder qualified as B
import Data.ByteString.Lazy qualified as LBS
import Data.List (intercalate)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Word (Word8, Word16)
import GHC.Generics (Generic)
import Numeric (showHex)
import PgWire.Binary.Types (PgDecode (..), PgEncode (..))
import PgWire.Protocol.Oid (oidInet)
-- | A PostgreSQL @inet@ or @cidr@ value.
--
-- Represents an IPv4 or IPv6 address with a prefix length (CIDR mask).
-- The 'inetIsCidr' flag distinguishes between @inet@ (host + optional mask)
-- and @cidr@ (network address, host bits must be zero).
data PgInet = PgInet
{ inetAddress :: !ByteString
-- ^ Raw address bytes: 4 bytes for IPv4, 16 bytes for IPv6.
, inetPrefix :: {-# UNPACK #-} !Word8
-- ^ Prefix length (0–32 for IPv4, 0–128 for IPv6).
, inetIsCidr :: !Bool
-- ^ 'True' for @cidr@, 'False' for @inet@.
}
deriving stock (Eq, Ord, Generic)
instance NFData PgInet
-- | Renders in CIDR notation: @\"192.168.1.0/24\"@ or @\"::1/128\"@.
instance Show PgInet where
show = T.unpack . inetToText
-- | Construct an IPv4 @inet@ value with a prefix length.
--
-- @ipv4 192 168 1 0 24@ represents @192.168.1.0\/24@.
ipv4 :: Word8 -> Word8 -> Word8 -> Word8 -> Word8 -> PgInet
ipv4 a b c d prefix =
PgInet
{ inetAddress = BS.pack [a, b, c, d]
, inetPrefix = prefix
, inetIsCidr = False
}
-- | Construct an IPv4 @inet@ host (prefix = 32).
ipv4Host :: Word8 -> Word8 -> Word8 -> Word8 -> PgInet
ipv4Host a b c d = ipv4 a b c d 32
-- | Construct an IPv6 @inet@ value from raw address bytes with a prefix
-- length. The 'ByteString' must be exactly 16 bytes.
ipv6 :: ByteString -> Word8 -> PgInet
ipv6 addr prefix =
PgInet
{ inetAddress = addr
, inetPrefix = prefix
, inetIsCidr = False
}
-- | Construct an IPv6 @inet@ host (prefix = 128) from raw address bytes.
-- The 'ByteString' must be exactly 16 bytes.
ipv6Host :: ByteString -> PgInet
ipv6Host addr = ipv6 addr 128
-- | Render a 'PgInet' as CIDR notation text.
--
-- >>> inetToText (ipv4 192 168 1 0 24)
-- "192.168.1.0/24"
-- >>> inetToText (ipv6Host (BS.pack [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1]))
-- "::1/128"
inetToText :: PgInet -> Text
inetToText (PgInet addr prefix _isCidr)
| BS.length addr == 4 = renderIPv4 addr <> "/" <> T.pack (show prefix)
| BS.length addr == 16 = renderIPv6 addr <> "/" <> T.pack (show prefix)
| otherwise = "<invalid inet>"
renderIPv4 :: ByteString -> Text
renderIPv4 bs =
T.pack $
intercalate "." $
map (show . BS.index bs) [0 .. 3]
renderIPv6 :: ByteString -> Text
renderIPv6 bs =
let groups :: [Word16]
groups =
[ fromIntegral (BS.index bs i) * 256 + fromIntegral (BS.index bs (i + 1))
| i <- [0, 2 .. 14]
]
hexGroups = map (\g -> T.pack (showHex g "")) groups
in compressIPv6 hexGroups
-- | Compress an IPv6 address by replacing the longest run of zero groups
-- with "::".
compressIPv6 :: [Text] -> Text
compressIPv6 groups =
let isZero g = g == "0"
-- Find runs of zeros
indexed = zip [0 :: Int ..] (map isZero groups)
runs = findZeroRuns indexed
in case runs of
[] -> T.intercalate ":" groups
_ ->
let (start, len) = longestRun runs
before = take start groups
after = drop (start + len) groups
in case (null before, null after) of
(True, True) -> "::"
(True, False) -> "::" <> T.intercalate ":" after
(False, True) -> T.intercalate ":" before <> "::"
(False, False) -> T.intercalate ":" before <> "::" <> T.intercalate ":" after
findZeroRuns :: [(Int, Bool)] -> [(Int, Int)]
findZeroRuns [] = []
findZeroRuns ((i, True) : rest) =
let runLen = 1 + length (takeWhile snd rest)
remaining = drop (runLen - 1) rest
in (i, runLen) : findZeroRuns remaining
findZeroRuns (_ : rest) = findZeroRuns rest
longestRun :: [(Int, Int)] -> (Int, Int)
longestRun = foldl1 (\a@(_, la) b@(_, lb) -> if lb > la then b else a)
-- Codec -------------------------------------------------------------------
-- PG address family codes
pgAfInet :: Word8
pgAfInet = 2
pgAfInet6 :: Word8
pgAfInet6 = 3
instance PgEncode PgInet where
pgEncode (PgInet addr prefix isCidr) =
let addrLen = BS.length addr
family
| addrLen == 4 = pgAfInet
| otherwise = pgAfInet6
cidrFlag :: Word8
cidrFlag = if isCidr then 1 else 0
in LBS.toStrict . B.toLazyByteString $
B.word8 family
<> B.word8 prefix
<> B.word8 cidrFlag
<> B.word8 (fromIntegral addrLen)
<> B.byteString addr
{-# INLINE pgEncode #-}
pgOid _ = oidInet
{-# INLINE pgOid #-}
instance PgDecode PgInet where
pgDecode bs
| BS.length bs < 4 = Left $ "inet: expected at least 4 header bytes, got " <> show (BS.length bs)
| otherwise =
let family = BS.index bs 0
prefix = BS.index bs 1
cidrFlag = BS.index bs 2
addrLen = fromIntegral (BS.index bs 3) :: Int
expectedFamily
| addrLen == 4 = pgAfInet
| addrLen == 16 = pgAfInet6
| otherwise = 0
in if BS.length bs /= 4 + addrLen
then Left $ "inet: expected " <> show (4 + addrLen) <> " bytes, got " <> show (BS.length bs)
else
if family /= expectedFamily
then Left $ "inet: address family mismatch: " <> show family <> " for " <> show addrLen <> " byte address"
else
Right
PgInet
{ inetAddress = BS.drop 4 bs
, inetPrefix = prefix
, inetIsCidr = cidrFlag /= 0
}
{-# INLINE pgDecode #-}