packages feed

dns-patterns-0.1: lib/Network/DNS/Pattern.hs

-- |
-- Module      : Network.DNS.Pattern
-- Description : Generic network related types.
--
-- Provides utilities and parsers for a simple domain name pattern language.
{-# LANGUAGE OverloadedStrings #-}
module Network.DNS.Pattern
  (
  -- * Domain names
  -- $domainNames
    parseAbsDomain
  , parseAbsDomainRelax
  , parseDomainLabel
  , absDomainP
  , absDomainRelaxP
  , Domain(..)
  , DomainLabel(..)
  , pprDomain
  , pprDomainLabel

  -- * Pattern language
  -- $patterns
  , parsePattern
  , patternWorksInside
  , matchesPattern
  , domainLabelP
  , patternP
  , DomainPattern(..)
  , LabelPattern(..)
  , encodedLength
  , pprPattern
  )
where

import           Control.Monad (when)
import           Data.Char (isAscii, ord)
import           Data.Coerce (coerce)
import           Data.Foldable (asum, foldl')
import           Data.Word (Word8)
import           Text.Printf (printf)

import           Control.Applicative.Combinators
import           Data.Attoparsec.Text ((<?>))
import qualified Data.Attoparsec.Text as A
import           Data.ByteString.Internal (c2w, w2c)
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Builder as TB

import qualified Data.ByteString as BS
--
-- * Domain names
--
-- $domainNames
--
-- There is no standardized presentation and parsing format for domain names.
-- In this library we assume a domain name and pattern to be specified as a text with an ASCII dot @.@ acting as a separator and terminator.
-- We do not admit arbitrary unicode codepoints, only ASCII is acceptable. Punycoding, if desired, must be taken care of the user.
--
-- Escape sequences
-- The domain name and pattern language here allows for the following escape sequences
--
-- @
--    \\.      gives a dot inside a label, rather than a label separator
--    \\\\      gives a backslash inside a label
--    \\012    gives an arbitrary octet inside a label as specified by the three octets
-- @
--
-- For example: @foo\\.bar.quux.@ is a domain name comprised of two labels @foo.bar@ and @quux@


-- * Patterns
--
-- $patterns
--
-- Patterns can be simple absolute domain names, where labels can be replaced with either a single glob @*@ or a globstar @**@.
-- A single glob will match any label in its place, where globstar will greedily match as many labels as possible.
--
-- Admits the escape sequences from domain names as well as the following
--
-- @
--    \\*   gives an asterisk inside a label, rather than a glob/globstar.
-- @
--
--
-- Note: Currently a globstar is only supported on the left-most label.
--
-- Examples or valid patterns are:
--
-- @
--    *.foo.bar.
--    **.foo.bar.
--    foo.*.bar.
--    foo.bar.*.
-- @


type Parser = A.Parser

-- | A domain pattern.
newtype DomainPattern = DomainPattern
  { getDomainPattern :: [LabelPattern]
  } deriving (Eq, Ord)

-- | A domain parsed into labels. Each label is a 'BS.ByteString' rather than 'T.Text' or 'String' because a label can contain arbitrary bytes.
-- However, the 'Ord' and 'Eq' instances do limited case-folding according to [RFC4343](https://datatracker.ietf.org/doc/html/rfc4343#section-3).
newtype Domain = Domain
  { getDomain :: [DomainLabel]
  } deriving (Eq, Ord)

-- | Newtype warpper for 'BS.ByteString' that implements case-insensitive 'Eq' and 'Ord' as per [RFC4343](https://datatracker.ietf.org/doc/html/rfc4343#section-3).
newtype DomainLabel = DomainLabel { getDomainLabel :: BS.ByteString }
  deriving (Ord)

instance Eq DomainLabel where
  DomainLabel l == DomainLabel r =
        BS.map caseFold l == BS.map caseFold r
    where
        caseFold :: Word8 -> Word8
        caseFold x = case x of
            0x41 -> c2w 'a'
            0x42 -> c2w 'b'
            0x43 -> c2w 'c'
            0x44 -> c2w 'd'
            0x45 -> c2w 'e'
            0x46 -> c2w 'f'
            0x47 -> c2w 'g'
            0x48 -> c2w 'h'
            0x49 -> c2w 'i'
            0x4a -> c2w 'j'
            0x4b -> c2w 'k'
            0x4c -> c2w 'l'
            0x4d -> c2w 'm'
            0x4e -> c2w 'n'
            0x4f -> c2w 'o'
            0x50 -> c2w 'p'
            0x51 -> c2w 'q'
            0x52 -> c2w 'r'
            0x53 -> c2w 's'
            0x54 -> c2w 't'
            0x55 -> c2w 'u'
            0x56 -> c2w 'v'
            0x57 -> c2w 'w'
            0x58 -> c2w 'x'
            0x59 -> c2w 'y'
            0x5a -> c2w 'z'
            _    -> x

-- | Print an arbitrary domain into a presentation format.
--
-- This function nearly roundtrips with 'parseAbsDomain' in the sense that octet escape sequences might change case or drop a leading zero.
--
-- prop> parseAbsDomain . pretty ~~~ id
pprDomain :: Domain -> T.Text
pprDomain (Domain l) = TL.toStrict (TB.toLazyText build)
  where
    build :: TB.Builder
    build = foldl' (\buf x -> buf <> buildLabel x <> ".") mempty l

-- | Print a singular domain label into a presentation format.
pprDomainLabel :: DomainLabel -> T.Text
pprDomainLabel = TL.toStrict . TB.toLazyText . buildLabel

-- | Print domain into presentation format.
--
-- This function nearly roundtrips with 'parsePattern' in the sense that octet escape sequences might change case or drop a leading zero.
--
-- prop> parsePattern . pprPattern ~~~ id
pprPattern :: DomainPattern -> T.Text
pprPattern (DomainPattern l) = TL.toStrict (TB.toLazyText build)
  where
    build :: TB.Builder
    build = foldl' (\buf x -> buf <> buildLabelPattern x <> ".") mempty l

buildLabel :: DomainLabel -> TB.Builder
buildLabel (DomainLabel d) = BS.foldl' (\buf x -> buf <> go x) mempty d
  where
    go :: Word8 -> TB.Builder
    go 0x2e = "\\."
    go 0x5c = "\\\\"
    go c | c  > 0x20
         && c <= 0x7E
         = TB.singleton (w2c c)

         | otherwise
         = "\\" <> TB.fromString (printf "%03o" c)

buildLabelPattern :: LabelPattern -> TB.Builder
buildLabelPattern (DomLiteral d) = BS.foldl' (\buf x -> buf <> go x) mempty d
  where
    go :: Word8 -> TB.Builder
    go 0x2e = "\\."
    go 0x5c = "\\\\"
    go 0x2a = "\\*"
    go c |  c >  0x20
         && c <= 0x7E = TB.singleton (w2c c)

         | otherwise
         = "\\" <> TB.fromString (printf "%03o" c)
buildLabelPattern DomGlob     = "*"
buildLabelPattern DomGlobStar = "**"

-- | A pattern for a singular label.
data LabelPattern
  = DomLiteral BS.ByteString -- ^ Represents an exact label that must be matched.
  | DomGlob -- ^ Represents a single asterisk glob matching any arbitrary domain at a given level.
  | DomGlobStar -- ^ Represents a double asterisk matching any arbitrary subdomain at a given level.
  deriving (Eq, Ord, Show)

-- | Given a pattern and a DNS zone specified by a domain name, test whether or not the pattern
-- is applicable beneath that zone.
--
-- @
--   foo.*.bar.  applicable inside zone          quux.bar.
--   foo.bar.    applicable inside zone          bar.
--   bar.        applicable inside zone          bar.
--   foo.bar.    not applicable inside zone      quux.
-- @
patternWorksInside :: DomainPattern -> Domain -> Bool
patternWorksInside (DomainPattern x) (Domain y) = go (reverse x) (reverse y)
  where
    go :: [LabelPattern] -> [DomainLabel] -> Bool
    go [DomGlobStar] _ = True
    go [] []           = True
    go [] _ls          = False
    go _p []           = True
    go (p:ps) (l:ls)   = labelMatchesPattern (getDomainLabel l) p && go ps ls

-- | Test whether a given domain matches a 'DomainPattern'
matchesPattern :: Domain -> DomainPattern -> Bool
matchesPattern (Domain x) (DomainPattern y) = go (reverse x) (reverse y)
  where
    go :: [DomainLabel] -> [LabelPattern] -> Bool
    go []   []            = True
    go []  _ps            = False
    go _ls  []            = False
    go _ls  [DomGlobStar] = True
    go (l:ls) (p:ps)      = labelMatchesPattern (getDomainLabel l) p && go ls ps

-- | Test whether a single label matches a label pattern
labelMatchesPattern :: BS.ByteString -> LabelPattern -> Bool
labelMatchesPattern _l DomGlob        = True
labelMatchesPattern  l (DomLiteral p) = l == p
labelMatchesPattern _l DomGlobStar    = True


-- | Parser for domain patterns. See 'parsePattern' for a convenince wrapper.
patternP :: Parser DomainPattern
patternP = asum [ do p  <- litGlobStar <* A.char '.'
                     ps <- litGlob `endBy` A.char '.'
                     pure (DomainPattern (p:ps))

                , DomainPattern [] <$ A.char '.' -- Literal pattern of the root domain "."
                ]
  where
    litGlobStar :: Parser LabelPattern
    litGlobStar = asum [ DomLiteral <$> patternLabelP
                       , DomGlobStar <$ A.string "**"
                       , DomGlob <$ A.char '*'
                       ]

    litGlob :: Parser LabelPattern
    litGlob = asum [ DomLiteral <$> patternLabelP
                   , DomGlob <$ A.char '*'
                   ]

-- | Parse a domain pattern. Convenience wrapper for 'patternP.
parsePattern :: T.Text -> Either String DomainPattern
parsePattern = A.parseOnly (patternP <* A.endOfInput)

-- | Parse an absolute domain. Convenience wrapper for 'absDomainP'.
parseAbsDomain :: T.Text -> Either String Domain
parseAbsDomain = A.parseOnly (absDomainP <* A.endOfInput)

-- | Parse a singular domain label. Convenience wrapper for 'domainLabelP'.
parseDomainLabel :: T.Text -> Either String DomainLabel
parseDomainLabel = A.parseOnly (domainLabelP <* A.endOfInput)

-- | Version of parseAbsDomain that also considers a domain name without a trailing dot
-- to be absolute.
parseAbsDomainRelax :: T.Text -> Either String Domain
parseAbsDomainRelax = A.parseOnly (absDomainRelaxP <* A.endOfInput)

-- | Parser for absolute domains. See 'parseAbsDomainRelax' for a convenience warpper.
-- This variant differs from 'absDomainP' in that it does not care whether the domain
-- name is terminated with a dot.
absDomainRelaxP :: Parser Domain
absDomainRelaxP = do
    d <- go
    let l = encodedLength d
    when (l >= 255) (fail "domain name too long")
    pure d

  where
    go = Domain <$> domainLabelP `sepBy1` A.char '.'

-- | Calculate the wire-encoded length of a domain name.
encodedLength :: Domain -> Int
encodedLength (Domain labels) = sum (BS.length <$> l') + length l'
  where
    l' = coerce labels :: [BS.ByteString]

-- | Parser for absolute domains. See 'parseAbsDomain' for a convenience wrapper.
-- For a parser that also admits domain forms without a leading dot, see 'absDomainRelaxP'.
absDomainP :: Parser Domain
absDomainP = do
    d <- go
    let l = encodedLength d
    when (l >= 255) (fail "domain name too long")
    pure d

  where
    go = Domain <$> asum
        [ domainLabelP `endBy1` A.char '.'
        , [] <$ A.char '.' -- The root domain itself
        ]

patternLabelP :: Parser BS.ByteString
patternLabelP = do
    r <- BS.pack <$> (some labelChar)
    when (BS.length r >= 64) (fail "label must not be longer than 63 octets")
    pure r
  where
    labelChar :: Parser Word8
    labelChar = do
        c <- A.satisfy (\x -> x /= '.'
                           && x /= '*'
                           && isAscii x
                        ) <?> "pattern label character"
        case c of
            '\\' -> escapable
            _    -> pure (c2w c)

    escapable :: Parser Word8
    escapable = asum
        [ c2w <$> A.char '.'
        , c2w <$> A.char '\\'
        , c2w <$> A.char '*'
        , octal
        ] <?> "escapable character"

octal :: Parser Word8
octal = do
  o1 <- v <$> A.satisfy isOctal
  o2 <- v <$> A.satisfy isOctal
  o3 <- v <$> A.satisfy isOctal
  pure (fromIntegral (o1 * 64 +
                      o2 * 8 +
                      o3))

  where
    v c = ord c - 48

    isOctal :: Char -> Bool
    isOctal c = c >= '0' && c <= '7'

-- | Parser for a singular domain label. See 'parseDomainLabel' for a convenince wrapper. Also see 'absDomainP'.
domainLabelP :: Parser DomainLabel
domainLabelP = DomainLabel . BS.pack <$> (some labelChar)
  where
    labelChar :: Parser Word8
    labelChar = do
        c <- A.satisfy (\x -> x /= '.'
                           && isAscii x
                       ) <?> "domain label character"
        case c of
            '\\' -> escapable
            _    -> pure (c2w c)

    escapable :: Parser Word8
    escapable = asum
        [ c2w <$> A.char '.'
        , c2w <$> A.char '\\'
        , octal
        ] <?> "escapable character"