Treat data: and file: URI schemes case-insensitively.

URI schemes are case-insensitive (RFC 3986, section 3.1), but several
places matched only lowercase "data:" or "file:":

- Text.Pandoc.URI.pBase64DataURI (also used by isURI)
- downloadOrRead: the fast-path prefix check and the parseURI
  fallback for data: and file: URIs
- Text.Pandoc.App.Input.readSource: file:, http:, and https: URIs
  given as input sources
- Text.Pandoc.MediaBag: canonicalize's fast path and insertMedia's
  data-URI branch

Previously, e.g. DATA:image/gif;base64,... would be handed to openURL
and the image dropped, and a large uppercase data URI would also force
a full parseURI in MediaBag.canonicalize. Now such URIs are handled
the same as their lowercase equivalents. Added a test case.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John MacFarlane
2026-09-09 23:30:32 -07:00
co-authored by Claude
parent 23bc02c63c
commit ac4abe44e3
5 changed files with 24 additions and 11 deletions
+4 -2
View File
@@ -16,6 +16,7 @@ module Text.Pandoc.App.Input
import Control.Monad ((>=>), when)
import Control.Monad.Except (throwError, catchError)
import Data.Char (toLower)
import Data.Text (Text)
import Network.URI (URI (..), parseURI)
import Text.Pandoc.Transforms (adjustLinksAndIds)
@@ -89,8 +90,9 @@ readSource :: PandocMonad m
readSource "-" = (,Nothing) <$> readStdinStrict
readSource src =
case parseURI src of
Just u | uriScheme u `elem` ["http:","https:"] -> openURL (T.pack src)
| uriScheme u == "file:" ->
Just u | map toLower (uriScheme u) `elem` ["http:","https:"] ->
openURL (T.pack src)
| map toLower (uriScheme u) == "file:" ->
(,Nothing) <$>
readFileStrict (uriPathToPath $ T.pack $ uriPath u)
_ -> (,Nothing) <$> readFileStrict src
+5 -4
View File
@@ -65,7 +65,7 @@ module Text.Pandoc.Class.PandocMonad
import Control.Monad.Except (MonadError (catchError, throwError))
import Control.Monad.Trans (MonadTrans, lift)
import Control.Monad (when)
import Data.Char (chr, digitToInt, isHexDigit)
import Data.Char (chr, digitToInt, isHexDigit, toLower)
import Data.List (intercalate)
import Data.Word (Word8)
import Data.Time (UTCTime)
@@ -386,7 +386,7 @@ downloadOrRead :: PandocMonad m
=> T.Text
-> m (B.ByteString, Maybe MimeType)
downloadOrRead s
| "data:" `T.isPrefixOf` s,
| T.toLower (T.take 5 s) == "data:",
Right (bs, mt) <- A.parseOnly (pBase64DataURI <* A.endOfInput) s
= pure (bs, Just mt)
| otherwise = do
@@ -403,9 +403,10 @@ downloadOrRead s
Nothing -> openURL s' -- will throw error
(Nothing, s') ->
case parseURI (T.unpack s') of -- requires absolute URI
Just URI{ uriScheme = "file:", uriPath = upath}
Just URI{ uriScheme = sch, uriPath = upath}
| map toLower sch == "file:"
-> readLocalFile $ uriPathToPath (T.pack upath)
Just URI{ uriScheme = "data:", uriPath = upath}
| map toLower sch == "data:"
-> pure $ extractURIData upath
-- We don't want to treat C:/ as a scheme:
Just u' | length (uriScheme u') > 2 -> openURL (T.pack $ show u')
+8 -3
View File
@@ -25,6 +25,7 @@ module Text.Pandoc.MediaBag (
) where
import Crypto.Hash (hashlazy, Digest, SHA1)
import qualified Data.ByteString.Lazy as BL
import Data.Char (toLower)
import Data.Data (Data)
import qualified Data.Map as M
import Data.Maybe (fromMaybe, isNothing)
@@ -55,12 +56,16 @@ newtype MediaBag = MediaBag (M.Map Text MediaItem)
instance Show MediaBag where
show bag = "MediaBag " ++ show (mediaDirectory bag)
-- | Check for the (case-insensitive) @data:@ URI scheme.
isDataURI :: FilePath -> Bool
isDataURI = (== "data:") . map toLower . take 5
-- | We represent paths with /, in normalized form. Percent-encoding
-- is not resolved.
canonicalize :: FilePath -> Text
-- avoid an expensive call to isURI for data URIs:
canonicalize fp@('d':'a':'t':'a':':':_) = T.pack fp
canonicalize fp
-- avoid an expensive call to isURI for data URIs:
| isDataURI fp = T.pack fp
| isURI fp = T.pack fp
| otherwise = T.replace "\\" "/" . T.pack . normalise $ fp
@@ -80,7 +85,7 @@ insertMedia :: FilePath -- ^ relative path and canonical name of resource
-> MediaBag
-> MediaBag
insertMedia fp mbMime contents (MediaBag mediamap)
| 'd':'a':'t':'a':':':_ <- fp
| isDataURI fp
, Just mt' <- mbMime
= MediaBag (M.insert fp'
MediaItem{ mediaPath = hashpath
+1 -1
View File
@@ -126,7 +126,7 @@ pBase64DataURI :: A.Parser (B.ByteString, MimeType)
pBase64DataURI = base64uri
where
base64uri = do
A.string "data:"
A.asciiCI "data:" -- the scheme is case-insensitive (RFC 3986)
mime <- do
n1 <- restrictedName
A.char '/'
+6 -1
View File
@@ -27,7 +27,9 @@ tests = [
-- absolute path -> extracted with hashed name
B.para (B.image (T.pack absLalune) "" mempty) <>
B.para (B.image "data:image/png;base64,cHJpbnQgImhlbGxvIgo=;.lua+%2f%2e%2e%2f%2e%2e%2fa%2elua" "" mempty) <>
B.para (B.image "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" "" mempty)
B.para (B.image "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" "" mempty) <>
-- the data: scheme is case-insensitive
B.para (B.image "DATA:image/gif;base64,dXBwZXJjYXNlIGRhdGEgdXJpIHRlc3QK" "" mempty)
let fooDir = absTmpdir </> "foo"
runIOorExplode $ do
fillMediaBag d
@@ -42,6 +44,9 @@ tests = [
(exists3 && not exists4)
exists5 <- doesFileExist (fooDir </> "d5fceb6532643d0d84ffe09c40c481ecdf59e15a.gif")
assertBool "data uri with gif is not properly decoded" exists5
exists5a <- doesFileExist
(fooDir </> "81c7546d23179ce1b344a763aa9038c3a8ff85d0.gif")
assertBool "data uri with uppercase scheme is not extracted" exists5a
-- double-encoded version:
let e = B.doc $
B.para (B.image "data:image/png;base64,cHJpbnQgInB3bmVkIgo=;.lua+%252f%252e%252e%252f%252e%252e%252fb%252elua" "" mempty)