file-uri-0.1.0.0: lib/System/URI/File/Internal.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TupleSections #-}
module System.URI.File.Internal where
import Control.Applicative
import Control.Monad
import Data.Attoparsec.ByteString (Parser)
import Data.Bits
import Data.ByteString (ByteString)
import Data.Maybe
import Data.Ix (range)
import Data.Word
import qualified Data.Attoparsec.ByteString as A
import qualified Data.Attoparsec.ByteString.Char8 as A (decimal)
import qualified Data.ByteString as BS
-- $setup
-- >>> :set -XOverloadedStrings
-----------------------
--[ Main data types ]--
-----------------------
-- | A parsed file URI. It can have an auth/host part.
data FileURI = FileURI {
fileAuth :: Maybe ByteString -- ^ optional host part ("localhost" is parsed as 'Nothing'); <https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats#unc-paths UNC> paths on windows go into 'filePath' and are not split
, filePath :: ByteString -- ^ the proper absolute filepath
} deriving (Show, Eq)
-- | RFC syntax configuration.
data ParseSyntax = StrictPosix -- ^ Only parses the strict syntax according to <https://www.rfc-editor.org/rfc/rfc8089.html#section-2 section 2 of RFC 8089>, which is technically posix paths.
| ExtendedPosix -- ^ Also parses extended user information described in <https://www.rfc-editor.org/rfc/rfc8089.html#appendix-E.1 E.1>
| ExtendedWindows -- ^ Parses windows paths according to <https://www.rfc-editor.org/rfc/rfc8089.html#appendix-E.1 E.1>, <https://www.rfc-editor.org/rfc/rfc8089.html#appendix-E.2 E.2> and <https://www.rfc-editor.org/rfc/rfc8089.html#appendix-E.3 E.3>. Unlike the spec, posix paths are rejected.
deriving (Show, Eq)
---------------
--[ Parsing ]--
---------------
-- | Parse a file URI such as @file:\/\/\/foo\/bar@ into 'FileURI'.
--
-- >>> parseFileURI StrictPosix "file:/path/to/file"
-- Right (FileURI {fileAuth = Nothing, filePath = "/path/to/file"})
-- >>> parseFileURI StrictPosix "file:///path/to/file"
-- Right (FileURI {fileAuth = Nothing, filePath = "/path/to/file"})
-- >>> parseFileURI StrictPosix "file://hostname/path/to/file"
-- Right (FileURI {fileAuth = Just "hostname", filePath = "/path/to/file"})
-- >>> parseFileURI StrictPosix "file://localhost/path/to/file"
-- Right (FileURI {fileAuth = Nothing, filePath = "/path/to/file"})
-- >>> parseFileURI StrictPosix "http://localhost/path/to/file"
-- Left "string"
-- >>> parseFileURI StrictPosix "/path/to/file"
-- Left "string"
-- >>> parseFileURI ExtendedWindows "file://///host.example.com/path/to/file"
-- Right (FileURI {fileAuth = Nothing, filePath = "//host.example.com/path/to/file"})
-- >>> parseFileURI ExtendedWindows "file:///c:/path/to/file"
-- Right (FileURI {fileAuth = Nothing, filePath = "c:/path/to/file"})
-- >>> parseFileURI ExtendedWindows "file:/c:/path/to/file"
-- Right (FileURI {fileAuth = Nothing, filePath = "c:/path/to/file"})
-- >>> parseFileURI ExtendedWindows "file:c:/path/to/file"
-- Right (FileURI {fileAuth = Nothing, filePath = "c:/path/to/file"})
parseFileURI :: ParseSyntax -- ^ RFC syntax configuration
-> ByteString -- ^ input file URI
-> Either String FileURI
parseFileURI StrictPosix = A.parseOnly (fileURIStrictP <* A.endOfInput)
parseFileURI ExtendedPosix = A.parseOnly (fileURIExtendedPosixP <* A.endOfInput)
parseFileURI ExtendedWindows = A.parseOnly (fileURIExtendedWindowsP <* A.endOfInput)
--------------------------
--[ Attoparsec parsers ]--
--------------------------
-- | Parse a file URI according to the <https://www.rfc-editor.org/rfc/rfc8089.html#section-2 main ABNF in RFC 8089>, without
-- any extended rules, which is as follows:
--
-- @
-- file-URI = file-scheme ":" file-hier-part
--
-- file-scheme = "file"
--
-- file-hier-part = ( "//" auth-path )
-- / local-path
--
-- auth-path = [ file-auth ] path-absolute
--
-- local-path = path-absolute
--
-- file-auth = "localhost"
-- / host
-- @
fileURIStrictP :: Parser FileURI
fileURIStrictP = A.string "file:" *> fileHierPart
where
fileHierPart = (A.string "//" *> authPath) <|> localPath
authPath = (\mfA -> FileURI (if mfA == Just "localhost" then Nothing else mfA))
<$> A.option Nothing (Just <$> fileAuth')
<*> pathAbsoluteP
fileAuth' = A.string "localhost" <|> hostP
localPath = FileURI Nothing <$> pathAbsoluteP
-- | Parse a file URI according to the <https://www.rfc-editor.org/rfc/rfc8089.html#section-2 main ABNF in RFC 8089>, with
-- extended rule <https://www.rfc-editor.org/rfc/rfc8089.html#appendix-E.1 E.1>.
--
-- @
-- file-URI = file-scheme ":" file-hier-part
--
-- file-scheme = "file"
--
-- file-hier-part = ( "//" auth-path )
-- / local-path
--
-- auth-path = [ file-auth ] path-absolute
--
-- local-path = path-absolute
--
-- file-auth = "localhost"
-- / [ userinfo "@" ] host
-- @
fileURIExtendedPosixP :: Parser FileURI
fileURIExtendedPosixP = A.string "file:" *> fileHierPart
where
fileHierPart = (A.string "//" *> authPath) <|> localPath
authPath = (\mfA -> FileURI (if mfA == Just "localhost" then Nothing else mfA))
<$> A.option Nothing (Just <$> fileAuth')
<*> pathAbsoluteP
fileAuth' = A.string "localhost" <|> sequenceM [A.option BS.empty (sequenceM [userInfoP, "@"]), hostP]
localPath = FileURI Nothing <$> pathAbsoluteP
-- | Parse a file URI according for windows according to <https://www.rfc-editor.org/rfc/rfc8089.html#appendix-E.1 E.1>,
-- <https://www.rfc-editor.org/rfc/rfc8089.html#appendix-E.2 E.2> and
-- <https://www.rfc-editor.org/rfc/rfc8089.html#appendix-E.3 E.3>. Unlike the spec, posix paths are rejected. The ABNF
-- is a slight modification of <https://www.rfc-editor.org/rfc/rfc8089.html#appendix-F Appendix F>.
--
-- @
-- file-URI = file-scheme ":" file-hier-part
--
-- file-scheme = "file"
--
-- file-hier-part = ( "//" auth-path )
-- / local-path
--
-- auth-path = [ file-auth ] file-absolute
-- / unc-authority path-absolute
--
-- local-path = drive-letter path-absolute
-- / file-absolute
--
-- file-auth = "localhost"
-- / [ userinfo "@" ] host
--
-- unc-authority = 2*3"/" file-host
--
-- file-host = inline-IP / IPv4address / reg-name
--
-- inline-IP = "%5B" ( IPv6address / IPvFuture ) "%5D"
--
-- file-absolute = "/" drive-letter path-absolute
--
-- drive-letter = ALPHA ":"
-- / ALPHA "|"
-- @
fileURIExtendedWindowsP :: Parser FileURI
fileURIExtendedWindowsP = A.string "file:" *> fileHierPart
where
fileHierPart = (A.string "//" *> authPath) <|> localPath
authPath = (\(mfA, p) -> FileURI (if mfA == Just "localhost" then Nothing else mfA) p) <$> (
((,) <$> A.option Nothing (Just <$> fileAuth') <*> fileAbsoluteP <* A.endOfInput)
<|> ((,) <$> pure Nothing <*> sequenceM [uncAuthorityP, pathAbsoluteP] <* A.endOfInput)
)
fileAuth' = A.string "localhost" <|> sequenceM [A.option BS.empty (sequenceM [userInfoP, "@"]), hostP]
localPath = fmap (FileURI Nothing) $ (fileAbsoluteP <* A.endOfInput) <|>
(sequenceM [driveLetterP', pathAbsoluteP] <* A.endOfInput)
pathAbsoluteP :: Parser ByteString
pathAbsoluteP = sequenceM [A.string "/", A.option BS.empty $ sequenceM [segmentNZP, pathAbEmpty]]
uncAuthorityP :: Parser ByteString
uncAuthorityP = (\a b -> BS.concat [a, b]) <$> (A.string "//" <* (A.option BS.empty (A.string "/"))) <*> fileHostP
fileHostP :: Parser ByteString
fileHostP = hostP
fileAbsoluteP :: Parser ByteString
fileAbsoluteP = A.string "/" *> sequenceM [driveLetterP', pathAbsoluteP]
driveLetterP :: Parser Word8
driveLetterP = A.satisfy (A.inClass alpha) <* (A.string ":" <|> A.string "|")
-- | Like 'driveLetterP', but appends ':'.
driveLetterP' :: Parser ByteString
driveLetterP' = ((<> ":") . BS.singleton) <$> driveLetterP
userInfoP :: Parser ByteString
userInfoP = BS.pack <$> many (pctEncodedP <|> satisfyClass (":" ++ subDelims ++ unreserved))
hostP :: Parser ByteString
hostP = ipLiteralP <|> ipV4P <|> regNameP
regNameP :: Parser ByteString
regNameP = BS.pack <$> A.many1 (pctEncodedP <|> satisfyClass (subDelims ++ unreserved))
ipLiteralP :: Parser ByteString
ipLiteralP = A.word8 oBracket *> (ipVFutureP <|> ipV6P) <* A.word8 cBracket
-- | Parses IPVFuture addresses. See relevant section in RFC.
ipVFutureP :: Parser ByteString
ipVFutureP = do
_ <- A.word8 lowercaseV
ds <- A.takeWhile1 hexDigit
_ <- A.word8 period
rest <- A.takeWhile1 $ A.inClass $ subDelims ++ ":" ++ unreserved
return $ "v" <> ds <> "." <> rest
where
lowercaseV = 118
-- | Parses IPV6 addresses. See relevant section in RFC.
ipV6P :: Parser ByteString
ipV6P = do
leading <- h16s
elided <- maybe [] (const [""]) <$> optional (A.string "::")
trailing <- many (A.takeWhile (/= colon) <* A.word8 colon)
(finalChunkLen, final) <- finalChunk
let len = length (leading ++ trailing) + finalChunkLen
when (len > 8) $ fail "Too many digits in IPv6 address"
return $ rejoin $ [rejoin leading] ++ elided ++ trailing ++ maybeToList final
where
finalChunk = fromMaybe (0, Nothing) <$> optional (finalIpV4 <|> finalH16)
finalH16 = (1,) . Just <$> h16
finalIpV4 = (2,) . Just <$> ipV4P
rejoin = BS.intercalate ":"
h16s = h16 `A.sepBy` A.word8 colon
h16 = mconcat <$> parseBetween 1 4 (A.takeWhile1 hexDigit)
-- | Parses a valid IPV4 address
ipV4P :: Parser ByteString
ipV4P =
sequenceM
[ decOctet,
dot,
decOctet,
dot,
decOctet,
dot,
decOctet
]
where
decOctet :: Parser ByteString
decOctet = do
(s, num) <- A.match A.decimal
let len = BS.length s
guard $ len <= 3
guard $ num >= (1 :: Int) && num <= 255
return s
dot = A.string "."
pathAbEmpty :: Parser ByteString
pathAbEmpty = fmap BS.concat . many . sequenceM $ [A.string "/", segmentP]
segmentP :: Parser ByteString
segmentP = BS.pack <$> A.many' pcharP
segmentNZP :: Parser ByteString
segmentNZP = BS.pack <$> A.many1 pcharP
segmentNZNCP :: Parser ByteString
segmentNZNCP = BS.pack <$> A.many1 (pctEncodedP <|> satisfyClass (subDelims ++ "@" ++ unreserved))
pcharP :: Parser Word8
pcharP = pctEncodedP <|> satisfyClass (subDelims ++ ":" ++ "@" ++ unreserved)
pctEncodedP :: Parser Word8
pctEncodedP = A.string "%" *> (decode <$> A.satisfy hexDigit <*> A.satisfy hexDigit)
where
decode w1 w2 = combine (hexVal w1) (hexVal w2)
hexVal w
| 48 <= w && w <= 57 = w - 48 -- 0 - 9
| 65 <= w && w <= 70 = w - 55 -- A - F
| 97 <= w && w <= 102 = w - 87 -- a - f
| otherwise = error $ "Not a hex value: " <> show w
combine a b = shiftL a 4 .|. b
---------------------------
--[ Word/String classes ]--
---------------------------
hexDigit :: Word8 -> Bool
hexDigit = A.inClass "0-9a-fA-F"
unreserved :: String
unreserved = alphaNum ++ "~._-"
subDelims :: String
subDelims = "!$&'()*+,;="
alphaNum :: String
alphaNum = alpha ++ digit
alpha :: String
alpha = "a-zA-Z"
digit :: String
digit = "0-9"
oBracket :: Word8
oBracket = 91
cBracket :: Word8
cBracket = 93
colon :: Word8
colon = 58
period :: Word8
period = 46
-------------------------------------
--[ Custom attoparsec combinators ]--
-------------------------------------
satisfyClass :: String -> Parser Word8
satisfyClass = A.satisfy . A.inClass
sequenceM :: Monad m => [(m ByteString)] -> m ByteString
sequenceM = fmap BS.concat . sequence
parseBetween :: (Alternative m, Monad m) => Int -> Int -> m a -> m [a]
parseBetween a b f = A.choice parsers
where
parsers = map (`A.count` f) $ reverse $ range (a, b)