packages feed

postgresql-types-0.1: src/library/PostgresqlTypes/Cidr.hs

module PostgresqlTypes.Cidr
  ( Cidr,

    -- * Accessors
    fold,
    refineToV4,
    refineToV6,

    -- * Constructors
    normalizeFromV4,
    normalizeFromV6,
    refineFromV4,
    refineFromV6,
  )
where

import qualified Data.Attoparsec.Text as Attoparsec
import Data.Bits
import PostgresqlTypes.Algebra
import PostgresqlTypes.Prelude hiding (fold)
import PostgresqlTypes.Via
import qualified PtrPeeker
import qualified PtrPoker.Write as Write
import qualified Test.QuickCheck as QuickCheck
import qualified TextBuilder

-- | PostgreSQL @cidr@ type.
--
-- Holds an IPv4 or IPv6 network specification (network address + netmask) where host bits must be zero.
-- If you want to store individual host addresses with optional netmasks, use the @inet@ type instead.
--
-- The input format for this type is @address/y@ where @address@ is an IPv4 or IPv6 network address (with host bits zeroed) and @y@ is the number of bits in the netmask. The @/y@ portion is required for @cidr@ type.
--
-- [PostgreSQL docs](https://www.postgresql.org/docs/18/datatype-net-types.html#DATATYPE-CIDR).
data Cidr
  = V4Cidr
      -- | IPv4 network address stored as 32-bit big-endian word (host bits must be zero).
      Word32
      -- | Network mask length (0-32).
      Word8
  | V6Cidr
      -- | First 32 bits of IPv6 network address in big-endian order.
      Word32
      -- | Second 32 bits of IPv6 network address in big-endian order.
      Word32
      -- | Third 32 bits of IPv6 network address in big-endian order.
      Word32
      -- | Fourth 32 bits of IPv6 network address in big-endian order.
      Word32
      -- | Network mask length (0-128).
      Word8
  deriving stock (Eq, Ord)
  deriving (Show, Read, IsString) via (ViaIsScalar Cidr)

instance Arbitrary Cidr where
  arbitrary = do
    isIPv4 <- arbitrary
    if isIPv4
      then do
        addr <- arbitrary
        netmask <- QuickCheck.choose (0, 32)
        pure (normalizeFromV4 addr netmask)
      else do
        w1 <- arbitrary
        w2 <- arbitrary
        w3 <- arbitrary
        w4 <- arbitrary
        netmask <- QuickCheck.choose (0, 128)
        pure (normalizeFromV6 w1 w2 w3 w4 netmask)

  shrink = \case
    V4Cidr addr netmask ->
      [ normalizeFromV4 addr' netmask'
      | addr' <- shrink addr,
        netmask' <- shrink netmask,
        netmask' <= 32
      ]
    V6Cidr w1 w2 w3 w4 netmask ->
      [ normalizeFromV6 w1' w2' w3' w4' netmask'
      | (w1', w2', w3', w4') <- shrink (w1, w2, w3, w4),
        netmask' <- shrink netmask,
        netmask' <= 128
      ]

instance Hashable Cidr where
  hashWithSalt salt = \case
    V4Cidr addr netmask -> salt `hashWithSalt` (0 :: Int) `hashWithSalt` addr `hashWithSalt` netmask
    V6Cidr w1 w2 w3 w4 netmask -> salt `hashWithSalt` (1 :: Int) `hashWithSalt` w1 `hashWithSalt` w2 `hashWithSalt` w3 `hashWithSalt` w4 `hashWithSalt` netmask

instance IsScalar Cidr where
  schemaName = Tagged Nothing
  typeName = Tagged "cidr"
  baseOid = Tagged (Just 650)
  arrayOid = Tagged (Just 651)
  typeParams = Tagged []

  binaryEncoder = \case
    V4Cidr addr netmask ->
      mconcat
        [ Write.word8 2, -- IPv4 address family
          Write.word8 netmask,
          Write.word8 1, -- is_cidr flag (1 for cidr)
          Write.word8 4, -- address length (4 bytes for IPv4)
          Write.bWord32 addr -- IPv4 network address
        ]
    V6Cidr w1 w2 w3 w4 netmask ->
      mconcat
        [ Write.word8 3, -- IPv6 address family for CIDR
          Write.word8 netmask,
          Write.word8 1, -- is_cidr flag (1 for cidr)
          Write.word8 16, -- address length (16 bytes for IPv6)
          Write.bWord32 w1,
          Write.bWord32 w2,
          Write.bWord32 w3,
          Write.bWord32 w4
        ]

  binaryDecoder = do
    (family, netmask, isCidrFlag, addrLen) <-
      PtrPeeker.fixed do
        (,,,)
          <$> PtrPeeker.unsignedInt1
          <*> PtrPeeker.unsignedInt1
          <*> PtrPeeker.unsignedInt1
          <*> PtrPeeker.unsignedInt1

    runExceptT do
      when (isCidrFlag /= 1) do
        throwError (DecodingError ["is-cidr"] (UnexpectedValueDecodingErrorReason "1" (TextBuilder.toText (TextBuilder.decimal isCidrFlag))))

      case family of
        2 -> do
          -- IPv4
          when (addrLen /= 4) do
            throwError (DecodingError ["address-length"] (UnexpectedValueDecodingErrorReason "4" (TextBuilder.toText (TextBuilder.decimal addrLen))))
          addr <- lift do
            PtrPeeker.fixed PtrPeeker.beUnsignedInt4
          pure (V4Cidr addr (fromIntegral netmask))
        3 -> do
          -- IPv6
          when (addrLen /= 16) do
            throwError (DecodingError ["address-length"] (UnexpectedValueDecodingErrorReason "16" (TextBuilder.toText (TextBuilder.decimal addrLen))))
          lift do
            PtrPeeker.fixed do
              V6Cidr
                <$> PtrPeeker.beUnsignedInt4
                <*> PtrPeeker.beUnsignedInt4
                <*> PtrPeeker.beUnsignedInt4
                <*> PtrPeeker.beUnsignedInt4
                <*> pure (fromIntegral netmask)
        _ -> do
          throwError (DecodingError ["address-family"] (UnexpectedValueDecodingErrorReason "2 or 3" (TextBuilder.toText (TextBuilder.decimal family))))

  textualEncoder = \case
    V4Cidr addr netmask ->
      let a = ((addr `shiftR` 24) .&. 0xFF)
          b = ((addr `shiftR` 16) .&. 0xFF)
          c = ((addr `shiftR` 8) .&. 0xFF)
          d = (addr .&. 0xFF)
       in mconcat
            [ TextBuilder.decimal a,
              ".",
              TextBuilder.decimal b,
              ".",
              TextBuilder.decimal c,
              ".",
              TextBuilder.decimal d,
              "/",
              TextBuilder.decimal netmask
            ]
    V6Cidr w1 w2 w3 w4 netmask ->
      -- Convert 32-bit words to proper IPv6 hex representation
      let toHex w =
            let h1 = fromIntegral ((w `shiftR` 16) .&. 0xFFFF) :: Word16
                h2 = fromIntegral (w .&. 0xFFFF) :: Word16
             in TextBuilder.hexadecimal h1 <> ":" <> TextBuilder.hexadecimal h2
       in toHex w1
            <> ":"
            <> toHex w2
            <> ":"
            <> toHex w3
            <> ":"
            <> toHex w4
            <> "/"
            <> TextBuilder.decimal netmask

  textualDecoder = parseV6 <|> parseV4
    where
      parseV4 = do
        a <- Attoparsec.decimal @Word32
        _ <- Attoparsec.char '.'
        b <- Attoparsec.decimal @Word32
        _ <- Attoparsec.char '.'
        c <- Attoparsec.decimal @Word32
        _ <- Attoparsec.char '.'
        d <- Attoparsec.decimal @Word32
        _ <- Attoparsec.char '/'
        netmask <- Attoparsec.decimal :: Attoparsec.Parser Word8
        let addr = (a `shiftL` 24) .|. (b `shiftL` 16) .|. (c `shiftL` 8) .|. d
        pure (normalizeFromV4 addr netmask)

      parseV6 = do
        -- Try to parse compressed IPv6 (with ::)
        parseCompressedV6 <|> parseFullV6

      parseFullV6 = do
        groups <- parseHexGroup `Attoparsec.sepBy1` Attoparsec.char ':'
        when (length groups /= 8) (fail "Expected 8 groups")
        _ <- Attoparsec.char '/'
        netmask <- Attoparsec.decimal :: Attoparsec.Parser Word8
        case groups of
          [h1, h2, h3, h4, h5, h6, h7, h8] -> do
            let w1 = fromIntegral h1 `shiftL` 16 .|. fromIntegral h2
                w2 = fromIntegral h3 `shiftL` 16 .|. fromIntegral h4
                w3 = fromIntegral h5 `shiftL` 16 .|. fromIntegral h6
                w4 = fromIntegral h7 `shiftL` 16 .|. fromIntegral h8
            pure (normalizeFromV6 w1 w2 w3 w4 netmask)
          _ -> fail "Expected 8 groups"

      parseCompressedV6 = do
        -- Check if starts with ::
        startsWithDoubleColon <- Attoparsec.option False (True <$ Attoparsec.string "::")
        before <-
          if startsWithDoubleColon
            then pure []
            else parseHexGroup `Attoparsec.sepBy1` Attoparsec.char ':'
        -- Check for :: in the middle or if we already found it at start
        hasDoubleColon <-
          if startsWithDoubleColon
            then pure True
            else Attoparsec.option False (True <$ Attoparsec.string "::")
        after <-
          if hasDoubleColon
            then parseHexGroup `Attoparsec.sepBy` Attoparsec.char ':'
            else pure []
        -- If no :: was found, this isn't compressed format
        when (not hasDoubleColon) (fail "Not a compressed IPv6 address")
        _ <- Attoparsec.char '/'
        netmask <- Attoparsec.decimal :: Attoparsec.Parser Word8
        -- Expand to 8 groups, filling middle with zeros
        let totalGroups = length before + length after
        when (totalGroups > 7) (fail "Too many groups in compressed IPv6")
        let zeros = replicate (8 - totalGroups) 0
            allGroups = before ++ zeros ++ after
        case allGroups of
          [h1, h2, h3, h4, h5, h6, h7, h8] -> do
            let w1 = fromIntegral h1 `shiftL` 16 .|. fromIntegral h2
                w2 = fromIntegral h3 `shiftL` 16 .|. fromIntegral h4
                w3 = fromIntegral h5 `shiftL` 16 .|. fromIntegral h6
                w4 = fromIntegral h7 `shiftL` 16 .|. fromIntegral h8
            pure (normalizeFromV6 w1 w2 w3 w4 netmask)
          _ -> fail "Expected 8 groups after expansion"

      parseHexGroup = Attoparsec.hexadecimal @Word16

-- * Accessors

-- |
-- Pattern match on 'Cidr' type.
fold ::
  -- | Function to handle IPv4 network case.
  --
  -- Takes the IPv4 network address as 'Word32' and the netmask as 'Word8' both in big-endian order.
  (Word32 -> Word8 -> a) ->
  -- | Function to handle IPv6 network case.
  --
  -- Takes the four 32-bit words of the IPv6 network address in big-endian order and the netmask as 'Word8'.
  (Word32 -> Word32 -> Word32 -> Word32 -> Word8 -> a) ->
  (Cidr -> a)
fold fV4 fV6 = \case
  V4Cidr addr netmask -> fV4 addr netmask
  V6Cidr w1 w2 w3 w4 netmask -> fV6 w1 w2 w3 w4 netmask

-- | Refine an IPv4 'Cidr' value from a 32-bit network address and an 8-bit netmask, both in big-endian order.
refineToV4 :: Cidr -> Maybe (Word32, Word8)
refineToV4 = \case
  V4Cidr addr netmask -> Just (addr, netmask)
  V6Cidr {} -> Nothing

-- | Refine an IPv6 'Cidr' value from four 32-bit words representing the network address and an 8-bit netmask, all in big-endian order.
refineToV6 :: Cidr -> Maybe (Word32, Word32, Word32, Word32, Word8)
refineToV6 = \case
  V4Cidr {} -> Nothing
  V6Cidr w1 w2 w3 w4 netmask -> Just (w1, w2, w3, w4, netmask)

-- * Constructors

-- |
-- Construct an IPv4 'Cidr' value from a 32-bit address and an 8-bit netmask, both in big-endian order.
--
-- The netmask is clamped to be in the range 0-32.
-- Host bits are automatically zeroed to ensure a valid network address.
normalizeFromV4 ::
  -- | IPv4 address as a 32-bit big-endian word.
  Word32 ->
  -- | Network mask length (0-32).
  Word8 ->
  Cidr
normalizeFromV4 addr netmask =
  let clampedNetmask = min 32 netmask
      hostBits = 32 - fromIntegral clampedNetmask
      networkMask = if hostBits >= 32 then 0 else complement ((1 `shiftL` hostBits) - 1)
      networkAddr = addr .&. networkMask
   in V4Cidr networkAddr clampedNetmask

-- |
-- Construct an IPv6 'Cidr' value from four 32-bit words representing the address and an 8-bit netmask, all in big-endian order.
--
-- The netmask is clamped to be in the range 0-128.
-- Host bits are automatically zeroed to ensure a valid network address.
normalizeFromV6 ::
  -- | First 32 bits of IPv6 address in big-endian order.
  Word32 ->
  -- | Second 32 bits of IPv6 address in big-endian order.
  Word32 ->
  -- | Third 32 bits of IPv6 address in big-endian order.
  Word32 ->
  -- | Fourth 32 bits of IPv6 address in big-endian order.
  Word32 ->
  -- | Network mask length (0-128).
  Word8 ->
  Cidr
normalizeFromV6 w1 w2 w3 w4 netmask =
  let clampedNetmask = min 128 netmask
      nm = fromIntegral clampedNetmask
      (nw1, nw2, nw3, nw4) = applyV6Mask nm w1 w2 w3 w4
   in V6Cidr nw1 nw2 nw3 nw4 clampedNetmask

-- | Refine an IPv4 'Cidr' value from a 32-bit address and an 8-bit netmask, both in big-endian order.
--
-- Returns 'Nothing' if the netmask is out of range (greater than 32) or if host bits are not zero.
refineFromV4 ::
  -- | IPv4 address as a 32-bit big-endian word.
  Word32 ->
  -- | Network mask length (0-32).
  Word8 ->
  Maybe Cidr
refineFromV4 addr netmask
  | netmask > 32 = Nothing
  | otherwise =
      let hostBits = 32 - fromIntegral netmask
          networkMask = if hostBits >= 32 then 0 else complement ((1 `shiftL` hostBits) - 1)
          networkAddr = addr .&. networkMask
       in if networkAddr == addr
            then Just (V4Cidr addr netmask)
            else Nothing

-- | Refine an IPv6 'Cidr' value from four 32-bit words representing the address and an 8-bit netmask, all in big-endian order.
--
-- Returns 'Nothing' if the netmask is out of range (greater than 128) or if host bits are not zero.
refineFromV6 ::
  -- | First 32 bits of IPv6 address in big-endian order.
  Word32 ->
  -- | Second 32 bits of IPv6 address in big-endian order.
  Word32 ->
  -- | Third 32 bits of IPv6 address in big-endian order.
  Word32 ->
  -- | Fourth 32 bits of IPv6 address in big-endian order.
  Word32 ->
  -- | Network mask length (0-128).
  Word8 ->
  Maybe Cidr
refineFromV6 w1 w2 w3 w4 netmask
  | netmask > 128 = Nothing
  | otherwise =
      let (nw1, nw2, nw3, nw4) = applyV6Mask (fromIntegral netmask) w1 w2 w3 w4
       in if (nw1, nw2, nw3, nw4) == (w1, w2, w3, w4)
            then Just (V6Cidr w1 w2 w3 w4 netmask)
            else Nothing

-- | Helper function to apply IPv6 network mask by zeroing out host bits.
--
-- Takes a netmask length (0-128) and four 32-bit words representing the IPv6 address.
-- Returns the four 32-bit words with host bits zeroed according to the netmask.
--
-- The masking is applied word by word, starting from the most significant word.
-- Each word represents 32 bits, so:
-- - netmask 0-32 affects only w1
-- - netmask 33-64 affects w1 and w2
-- - netmask 65-96 affects w1, w2, and w3
-- - netmask 97-128 affects all four words
applyV6Mask :: Int -> Word32 -> Word32 -> Word32 -> Word32 -> (Word32, Word32, Word32, Word32)
applyV6Mask netmask w1 w2 w3 w4
  | netmask <= 0 = (0, 0, 0, 0)
  | netmask >= 128 = (w1, w2, w3, w4)
  | netmask >= 96 = (w1, w2, w3, maskWord (netmask - 96) w4)
  | netmask >= 64 = (w1, w2, maskWord (netmask - 64) w3, 0)
  | netmask >= 32 = (w1, maskWord (netmask - 32) w2, 0, 0)
  | otherwise = (maskWord netmask w1, 0, 0, 0)
  where
    maskWord bits word
      | bits <= 0 = 0
      | bits >= 32 = word
      | otherwise =
          let hostBits = 32 - bits
              networkMask = complement ((1 `shiftL` hostBits) - 1)
           in word .&. networkMask