dns-patterns-0.3.1: lib/Network/DNS.hs
-- |
-- Module : Network.DNS
-- Description : Generic DNS utilities
--
-- 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 the following subset of ASCII is acceptable per label:
-- [a-z], [A-Z], [0-9], '_', '-', '*'
--
-- Punycoding, if desired, must be taken care of the user.
--
-- In addition, we allow a backslash to be used as an escaping character for the following possible sequences:
--
-- 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@
module Network.DNS
( Domain
, getDomain
, mkDomain
, mkDomain'
, DomainLabel
, getDomainLabel
, getDomainLabelCF
, mkDomainLabel
, unsafeMkDomainLabel
, unsafeSingletonDomainLabel
, foldCase
, foldCase_
, foldCaseLabel
-- * Parsing
, parseAbsDomain
, parseAbsDomainRelax
, parseDomainLabel
, absDomainP
, absDomainRelaxP
, domainLabelP
-- * Pretty printing
, pprDomain
, pprDomainCF
, pprDomainLabel
, pprDomainLabelCF
)
where
import Data.Char (isDigit, isLower, isUpper)
import Data.Coerce (coerce)
import Data.Word (Word8)
import Control.Applicative.Combinators
import Control.Monad (when)
import Data.ByteString.Internal (w2c)
import Data.Char (ord)
import Data.Foldable (asum)
import Data.Attoparsec.Text ((<?>))
import qualified Data.Attoparsec.Text as A
import Data.ByteString.Internal (c2w)
import qualified Data.ByteString.Short as BS
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import Network.DNS.Internal
-- | 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)
-- | Turn a 'Domain' into a list of its labels.
--
-- prop> getDomain . mkDomain ~~~ id
-- prop> mkDomain . getDomain ~~~ id
getDomain :: Domain -> [DomainLabel]
getDomain = coerce
-- | Turn a list of labels into a 'Domain'.
--
-- prop> getDomain . mkDomain ~~~ id
-- prop> mkDomain . getDomain ~~~ id
mkDomain :: [DomainLabel] -> Domain
mkDomain = coerce
-- | Turn a list of text labels into a 'Domain'
--
-- Codepoints outside ASCII are officially not supported.
mkDomain' :: [T.Text] -> Domain
mkDomain' xs = mkDomain (mkDomainLabel . BS.toShort . T.encodeUtf8 <$> xs)
-- | Get the wire-representation of a domain label.
{-# INLINE getDomainLabel #-}
getDomainLabel :: DomainLabel -> BS.ShortByteString
getDomainLabel = getDomainLabel_
-- | Get the [RFC4343](https://datatracker.ietf.org/doc/html/rfc4343#section-3) case-folded wire-representation of a domain label.
{-# INLINE getDomainLabelCF #-}
getDomainLabelCF :: DomainLabel -> BS.ShortByteString
getDomainLabelCF = getDomainLabelCF_
-- | Smart constructor for 'DomainLabel'
mkDomainLabel :: BS.ShortByteString -> DomainLabel
mkDomainLabel l = DomainLabel l (sbsMap foldCase_ l)
-- | Unsafely construct a 'DomainLabel'. The argument must already be case-folded according to [RFC4343](https://datatracker.ietf.org/doc/html/rfc4343#section-3).
unsafeMkDomainLabel :: BS.ShortByteString -> DomainLabel
unsafeMkDomainLabel l = DomainLabel l l
-- | Unsafely construct a 'DomainLabel' from a single Word8. The argument must already be case-folded according to [RFC4343](https://datatracker.ietf.org/doc/html/rfc4343#section-3).
unsafeSingletonDomainLabel :: Word8 -> DomainLabel
unsafeSingletonDomainLabel l = DomainLabel (sbsSingleton l) (sbsSingleton l)
-- | Case-folding of a domain according to [RFC4343](https://datatracker.ietf.org/doc/html/rfc4343#section-3).
-- Note 'Domain' will memoize a case-folded variant for 'Eq', 'Ord' and pretty printing already. This function is not useful to most.
foldCase :: Domain -> Domain
foldCase (Domain ls) = Domain (foldCaseLabel <$> ls)
-- | Case-folding of a domain label according to [RFC4343](https://datatracker.ietf.org/doc/html/rfc4343#section-3).
-- Note 'DomainLabel' will memoize a case-folded variant for 'Eq', 'Ord' and pretty printing already. This function is not useful to most.
{-# INLINE foldCaseLabel #-}
foldCaseLabel :: DomainLabel -> DomainLabel
foldCaseLabel (DomainLabel _l cf) = DomainLabel cf cf
{-# INLINE foldCase_ #-}
foldCase_ :: Word8 -> Word8
foldCase_ w | c2w 'A' <= w && w <= c2w 'Z'
= w + 0x20
| otherwise
= w
-- | Print an arbitrary domain into a presentation format.
--
-- This function nearly roundtrips with 'parseAbsDomain' up to escape sequence equivalence
--
-- prop> parseAbsDomain . pprDomain ~~~ id
pprDomain :: Domain -> T.Text
pprDomain (Domain l) = T.pack (fromDList build)
where
build :: DList Char
build = foldr (\x buf -> buildLabel x <> singleton '.' <> buf) mempty l
-- | Print an arbitrary domain into a presentation format after case-folding according to [RFC4343](https://datatracker.ietf.org/doc/html/rfc4343#section-3).
--
-- This function nearly roundtrips with 'parseAbsDomain' up to escape sequence equivalence and case folding.
--
-- prop> parseAbsDomain . pprDomainCF ~~~ id
pprDomainCF :: Domain -> T.Text
pprDomainCF (Domain l) = T.pack (fromDList build)
where
build :: DList Char
build = foldr (\x buf -> buildLabelCF x <> singleton '.' <> buf) mempty l
-- | Print a singular domain label into a presentation format.
pprDomainLabel :: DomainLabel -> T.Text
pprDomainLabel = T.pack . fromDList . buildLabel
-- | Print a singular domain label into a presentation format after case-folding according to [RFC4343](https://datatracker.ietf.org/doc/html/rfc4343#section-3).
pprDomainLabelCF :: DomainLabel -> T.Text
pprDomainLabelCF = T.pack . fromDList . buildLabelCF
-- | Attoparsec 'A.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 ends in a dot.
absDomainRelaxP :: A.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 '.' <* optional (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' :: [BS.ShortByteString]
l' = getDomainLabel <$> labels
-- | Attoparsec 'A.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 :: A.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
]
-- | Predicate selecting characters allowed in a domain label without escaping.
{-# INLINE isLabelChar #-}
isLabelChar :: Char -> Bool
isLabelChar x = isLower x || isDigit x || isUpper x || x == '-' || x == '_' || x == '*'
-- | Attoparsec 'A.Parser' for a singular domain label. See 'parseDomainLabel' for a convenince wrapper. Also see 'absDomainP'.
domainLabelP :: A.Parser DomainLabel
domainLabelP = do
label <- some labelChar
when (length label >= 64) $
fail "domain label too long"
pure (mkDomainLabel (BS.pack label))
where
labelChar :: A.Parser Word8
labelChar = do
c <- A.satisfy (\x -> isLabelChar x || x == '\\') <?> "domain label character"
case c of
'\\' -> escape
_ -> pure (c2w c)
escape :: A.Parser Word8
escape = asum [ c2w <$> A.char '.'
, c2w <$> A.char '\\'
, octal ]
<?> "escapable character"
octal :: A.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'
-- | Make a case-folded string from a 'DomainLabel' suitable for pretty printing
{-# INLINE buildLabelCF #-}
buildLabelCF :: DomainLabel -> DList Char
buildLabelCF = buildLabel_ . getDomainLabelCF_
-- | Make a string from a 'DomainLabel' suitable for pretty printing
{-# INLINE buildLabel #-}
buildLabel :: DomainLabel -> DList Char
buildLabel = buildLabel_ . getDomainLabel_
{-# INLINE buildLabel_ #-}
buildLabel_ :: BS.ShortByteString -> DList Char
buildLabel_ bs = toDList (replace (BS.unpack bs))
where
{-# INLINE replace #-}
replace :: [Word8] -> [Char]
replace (x:xs) = case x of
_ | isLabelChar (w2c x) -> (w2c x) : replace xs
0x2e -> '\\' : '.' : replace xs
0x5c -> '\\' : '\\' : replace xs
_ -> '\\' : o1 : o2 : o3 : replace xs
where
(o1, o2, o3) = case quotRem x 8 of
(v1, r3) -> case quotRem v1 8 of
(v2, r2) -> case quotRem v2 8 of
(_, r1) -> (showD r1, showD r2, showD r3)
replace [] = []
{-# INLINE showD #-}
showD :: Word8 -> Char
showD x = w2c (x + 0x30)