packages feed

dnsbase-1.0.2.3: internal/Net/DNSBase/Internal/NameComp.hs

-- |
-- Module      : Net.DNSBase.Internal.NameComp
-- Description : DNS name-compression state for the wire-format encoder
-- Copyright   : (c) Viktor Dukhovni, 2026
-- License     : BSD-3-Clause
-- Maintainer  : ietf-dane@dukhovni.org
-- Stability   : unstable
--
-- A mutable trie whose keys ('NCNode') pair a parent suffix's
-- message offset with a chunk slice of the owning name's wire form,
-- mapping each entry to the offset at which the corresponding suffix
-- first appears in the output message.  Used by
-- 'Net.DNSBase.Encode.Internal.State.putDomain' to emit DNS name
-- pointers per RFC 1035 §4.1.4.
--
-- A /chunk/ is a contiguous run of consecutive DNS labels (with their
-- length bytes) covering at least 'minChunkBytes' bytes of wire form.
-- Chunks are anchored at the tail of the name: starting from the
-- rightmost label, each chunk extends leftward until it reaches the
-- minimum-size threshold, at which point a new chunk begins.  The
-- leftmost chunk may be smaller than the threshold (it inherits
-- whatever labels are left over).  A label too short to make a
-- worthwhile pointer target on its own is therefore grouped with its
-- right neighbours.
--
-- The trie itself is a single flat hashtable; each name with @C@
-- chunks contributes @C@ entries, and each traversal step is one
-- hashtable lookup matching whole chunks.
--
-- The chunk component of each key is a slice (offset + length) into
-- a 'ShortByteString' belonging to the 'Domain' that first introduced
-- the chunk.  The slice retains a reference to that buffer for the
-- duration of the encode, so chunk bytes are not copied into freshly
-- allocated keys.
-- {-# LANGUAGE BangPatterns    #-}
{-# LANGUAGE MagicHash       #-}
-- {-# LANGUAGE PatternSynonyms #-}
module Net.DNSBase.Internal.NameComp
    ( NCMap
    , empty
    , lookupAndRegister
    ) where

import Net.DNSBase.Internal.Util

import qualified Data.ByteString.Short as SB
import qualified Data.HashTable.Class as H
import qualified Data.HashTable.ST.Basic as LH
import Control.Monad.ST (ST)
import Data.Foldable (foldlM)
import Data.Hashable (Hashable(..), hashByteArrayWithSalt)
import Data.Primitive.ByteArray
    ( ByteArray#
    , MutableByteArray
    , compareByteArrays
    , newByteArray
    , readByteArray
    , writeByteArray
    )

--------------------------------------------------------------------------------
-- Compression-trie node
--------------------------------------------------------------------------------

-- A compression-trie node identified by its parent suffix's
-- message-offset and a slice into the owning name's 'ShortByteString'
-- holding the chunk's wire bytes.  The byte array reference keeps
-- the source buffer alive as long as the node is reachable from the
-- map; 'Eq' and 'Hashable' read the byte range in place.
--
-- Field widths reflect the bounds: parent offsets are clamped at the
-- 14-bit pointer range (with @0xFFFF@ as the @ROOT@ sentinel,
-- distinct from any real offset because real offsets never exceed
-- @0x3FFF@), and a DNS name is at most 255 wire bytes so the chunk
-- offset and length both fit in a byte.
data NCNode = NCNode
                   !ByteArray#                  -- owning byte array
    {-# UNPACK #-} !Word16                      -- parent offset
    {-# UNPACK #-} !Word8                       -- chunk byte offset
    {-# UNPACK #-} !Word8                       -- chunk byte length

instance Eq NCNode where
    NCNode aA pA offA lenA == NCNode aB pB offB lenB
      | pA /= pB || lenA /= lenB = False
      | otherwise =
          compareByteArrays (ByteArray aA) (fromIntegral offA)
                            (ByteArray aB) (fromIntegral offB)
                            (fromIntegral lenA) == EQ

instance Hashable NCNode where
    hashWithSalt salt (NCNode ba parent off len) =
        hashByteArrayWithSalt ba (fromIntegral off) (fromIntegral len)
            (salt `hashWithSalt` (fromIntegral parent :: Int))

--------------------------------------------------------------------------------
-- Public API
--------------------------------------------------------------------------------

-- | The compression state: a single mutable hashtable keyed on
-- 'NCNode' (parent offset plus chunk slice), mapping each entry to
-- the message offset at which the suffix @chunk.parent-suffix@
-- first appears.
newtype NCMap s = NCMap (LH.HashTable s NCNode Int)

-- | Create a fresh empty compression state.
empty :: ST s (NCMap s)
empty = NCMap <$> H.new

-- The 14-bit pointer cap.  Children with absolute offsets exceeding
-- this can't be the target of an RFC 1035 compression pointer, so
-- registering them is pointless.
maxPtr :: Int
maxPtr = 0x3fff

-- Sentinel parent-offset for the root level of the trie.  Distinct
-- from any real wire-message offset, which is non-negative.
parentRoot :: Int
parentRoot = -1

-- The pointer high-bits mask: a name pointer is @0xC000 .|. target@.
pointerMark :: Int
pointerMark = 0xC000

-- Minimum byte count for a chunk.  A trie entry pointing at @n@ wire
-- bytes pays back @n - 2@ bytes on a hit, so chunks shorter than
-- about 4 bytes barely earn their keep; 8 puts every registered
-- chunk solidly into "worth pointing at" territory while still
-- exploiting the long zone-suffix sharing that dominates real DNS
-- traffic.
minChunkBytes :: Word8
minChunkBytes = 8

-- Max chunks per name.  Each chunk has at least 'minChunkBytes' wire
-- bytes except possibly the leftmost leftover, so at most
-- @floor(255 \/ minChunkBytes) + 1@.
maxChunks :: Int
maxChunks = fromIntegral $ 1 + 255 `quot` minChunkBytes

-- | Look up and register a domain in one walk.
--
-- Given the wire form @dname@ of a domain (including its terminal NUL
-- byte) about to be emitted at message offset @baseOff@, walk its
-- chunks root-first.  In the prefix that already matches the map,
-- thread parent offsets through hits; at the first miss (or running
-- off the end), register the new entries the input contributes.
-- Registrations whose absolute offsets exceed the 14-bit pointer
-- range ('maxPtr') are silently dropped: the input can still benefit
-- from compression against existing entries, but won't itself be the
-- target of future pointers.
--
-- Returns 'Nothing' when the name has no compressible suffix in the
-- map (so the caller emits the full wire form), or
-- @'Just' (headBytes, ptr)@ to indicate the caller should emit the
-- first @headBytes@ bytes of the wire form verbatim, then the 2-byte
-- pointer word @ptr@.
lookupAndRegister :: forall s. ShortByteString
                  -> Int
                  -> NCMap s
                  -> ST s (Maybe (Int, Word16))
lookupAndRegister !dname@(SBS nameArr) !baseOff (NCMap m) = do
    if | wlen == 1 -> pure Nothing -- root domain
       | otherwise -> do
            chunkArr <- newByteArray maxChunks
            nChunks  <- buildChunks chunkArr
            phase1 chunkArr nChunks 0 (wlen - 1) parentRoot Nothing
  where
    !wlen = SB.length dname

    -- Compute offsets of one-or-more chunks of consecutive labels,
    -- each at least 'minChunkBytes' wire bytes (the leftmost
    -- leftover may be smaller).  Chunks are emitted in root-first
    -- iteration order: @chunkArr[0]@ is the outermost (rightmost)
    -- chunk, @chunkArr[nChunks - 1]@ is the innermost (leftmost).
    --
    buildChunks :: MutableByteArray s -> ST s Int
    buildChunks carr = walk 0 []
      where
        walk :: Word8 -> [Word8] -> ST s Int
        walk !pos@((+ 1) . SB.index dname . fromIntegral -> !llen) !lens
            | llen /= 1 = walk (pos + llen) (llen : lens)
            | otherwise = foldlM step (0, 1, fromIntegral $ wlen - 1) lens >>= \ case
                (!ix, 0, _) -> pure ix
                (!ix, _, _) -> (ix + 1) <$ writeByteArray @Word8 carr ix 0

        step :: (Int, Word8, Word8) -> Word8 -> ST s (Int, Word8, Word8)
        step (!ix, !acc, !end) !llen
            | acc' < minChunkBytes = pure (ix, acc', start)
            | otherwise = (ix + 1, 0, start) <$ writeByteArray carr ix start
          where
            !acc'  = acc + llen
            !start = end - llen

    -- Lookup phase.  Walks chunks root-first; each step either
    -- advances the parent offset on a hit, or transitions to phase 2
    -- on the first miss.  @chunkEnd@ is the exclusive end position
    -- of the current chunk in the wire form (one past its last
    -- byte); each step's @chunkEnd@ becomes the next step's via the
    -- current chunk's start (chunks are contiguous in the wire form,
    -- with the next-outer chunk lying immediately to the right).
    -- The 'acc' carries @(target, matchedChunkStart)@ for the
    -- deepest match seen so far, or 'Nothing' if no chunk has
    -- matched yet.
    phase1
        :: MutableByteArray s
        -> Int                                          -- chunk count
        -> Int                                          -- current chunk index
        -> Int                                          -- chunk end (exclusive)
        -> Int                                          -- current parent_off
        -> Maybe (Int, Int)                             -- (target, matchedChunkStart)
        -> ST s (Maybe (Int, Word16))
    phase1 carr nChunks i chunkEnd parent acc
      | i >= nChunks = pure (finalize acc)
      | otherwise = do
          cs <- readByteArray carr i :: ST s Word8
          let !chunkStart = fromIntegral cs :: Int
              !chunkLen   = chunkEnd - chunkStart
              !node       = NCNode nameArr (fromIntegral parent)
                                   cs (fromIntegral chunkLen)
          mhit <- H.lookup m node
          case mhit of
              Just hitOff ->
                  phase1 carr nChunks (i + 1) chunkStart hitOff
                         (Just (hitOff, chunkStart))
              Nothing     -> phase2 carr nChunks i chunkEnd parent acc

    -- Insert phase.  Walks the remainder of chunks from index @i@
    -- onward, registering each at its absolute offset.  Drops
    -- out-of-range registrations silently.  Returns whatever
    -- compression decision was frozen at the transition.
    phase2
        :: MutableByteArray s
        -> Int
        -> Int
        -> Int
        -> Int
        -> Maybe (Int, Int)
        -> ST s (Maybe (Int, Word16))
    phase2 carr nChunks i chunkEnd parent acc
      | i >= nChunks = pure (finalize acc)
      | otherwise = do
          cs <- readByteArray carr i :: ST s Word8
          let !chunkStart = fromIntegral cs :: Int
              !chunkLen   = chunkEnd - chunkStart
              !node       = NCNode nameArr (fromIntegral parent)
                                   cs (fromIntegral chunkLen)
              !newOff     = baseOff + chunkStart
          when (newOff <= maxPtr) $
              H.insert m node newOff
          phase2 carr nChunks (i + 1) chunkStart newOff acc

    finalize :: Maybe (Int, Int) -> Maybe (Int, Word16)
    finalize Nothing           = Nothing
    finalize (Just (tgt, cs))  = Just (cs, mkPtr tgt)

    mkPtr :: Int -> Word16
    mkPtr off = fromIntegral (pointerMark .|. off)