packages feed

web-cookiejar (empty) → 0.1.0.0

raw patch · 7 files changed

+520/−0 lines, 7 filesdep +QuickCheckdep +attoparsecdep +basesetup-changed

Dependencies added: QuickCheck, attoparsec, base, bytestring, hspec, http-client, temporary, time, web-cookiejar

Files

+ ChangeLog.md view
@@ -0,0 +1,9 @@+# Revision history for web-cookiejar++`web-cookiejar` uses [PVP Versioning][1].++## 0.1.0.0 -- 2025-04-07++* Initial version.++[1]: https://pvp.haskell.org
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright (c) 2023, Tim Emiola++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * Neither the name of Tim Emiola nor the names of other+      contributors may be used to endorse or promote products derived+      from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ Setup.hs view
@@ -0,0 +1,4 @@+import Distribution.Simple+++main = defaultMain
+ src/Web/Cookie/Jar.hs view
@@ -0,0 +1,223 @@+{-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE OverloadedStrings #-}++{- | 'Parser' for a Netscape/Mozilla cookie jar++Provides parsing functions that parse the Netscape/Mozilla cookie jar file+format, along wiht @'Builder's@ that provide an incomplete roundtrip with the+parser.++The roundtrip is incomplete because some of the fields in @Cookie@ are not saved+in the Netscape/Mozilla cookie jar; see `cookieBuilder`.+-}+module Web.Cookie.Jar+  ( -- * read/write Cookie Jar files+    writeJar+  , writeJar'+  , writeNetscapeJar+  , readJar++    -- * Cookie jar format++    -- ** parsing+  , cookieJarParser+  , cookieParser+  , parseCookieJar++    -- ** printing+  , netscapeJarBuilder+  , jarBuilder+  , jarBuilder'+  , cookieBuilder++    -- * re-exports+  , parseOnly+  )+where++import Control.Applicative ((<|>))+import Control.Monad (void)+import Data.Attoparsec.ByteString.Char8+  ( Parser+  , char+  , decimal+  , endOfLine+  , isEndOfLine+  , many'+  , parseOnly+  , skipSpace+  , skipWhile+  , takeWhile1+  , try+  )+import Data.ByteString (ByteString)+import qualified Data.ByteString as B+import Data.ByteString.Builder+  ( Builder+  , byteString+  , char7+  , integerDec+  , toLazyByteString+  )+import qualified Data.ByteString.Lazy as L+import Data.Char (ord)+import Data.Time.Clock.POSIX+  ( posixSecondsToUTCTime+  , utcTimeToPOSIXSeconds+  )+import Network.HTTP.Client+  ( Cookie (..)+  , CookieJar+  , createCookieJar+  , destroyCookieJar+  )+++-- | Parse a @ByteString@ containing a cookie jar in the Netscape/Mozilla format+parseCookieJar :: ByteString -> Either String CookieJar+parseCookieJar = parseOnly cookieJarParser+++-- | @Parser@ for a cookie jar in the Netscape/Mozilla format+cookieJarParser :: Parser CookieJar+cookieJarParser = createCookieJar <$> many' cookieParser+++{- | Parser for one cookie/line in a cookie jar in the Netscape/Mozilla format+This will also consume any comment lines preceding the cookie line.++This parser recognizes the magic prefix @#HttpOnly_# and sets the appropriate+field in the @Cookie@ datatype+-}+cookieParser :: Parser Cookie+cookieParser =+  let+    httpOnlyLine = try $ "#HttpOnly_" *> cookieParser' True+    commentLine = "#" *> skipWhile notEndOfLine *> endOfLine *> cookieParser+    cookieLine = cookieParser' False+   in+    skipSpace *> (httpOnlyLine <|> commentLine <|> cookieLine)+++-- | Basic parser for a line containing a cookie in the Netscape/Mozilla format+cookieParser' :: Bool -> Parser Cookie+cookieParser' cookie_http_only = do+  let+    epoch = posixSecondsToUTCTime 0+    -- component parsers+    tab = void $ char '\t'+    parseString = takeWhile1 (/= '\t')+    parseBool = True <$ "TRUE" <|> False <$ "FALSE"+    parseTime = posixSecondsToUTCTime . fromInteger <$> decimal+    parseValue = takeWhile1 notEndOfLine+  cookie_domain <- parseString+  tab+  cookie_host_only <- parseBool+  tab+  cookie_path <- parseString+  tab+  cookie_secure_only <- parseBool+  tab+  cookie_expiry_time <- parseTime+  tab+  cookie_name <- parseString+  tab+  cookie_value <- parseValue+  endOfLine <|> pure ()+  pure $+    Cookie+      { cookie_domain+      , cookie_path+      , cookie_secure_only+      , cookie_expiry_time+      , cookie_name+      , cookie_value+      , cookie_host_only+      , cookie_http_only+      , -- fields not represented by the cookie jar format+        cookie_creation_time = epoch+      , cookie_last_access_time = epoch+      , cookie_persistent = True+      }+++notEndOfLine :: Char -> Bool+notEndOfLine = not . isEndOfLine . fromIntegral . ord+++-- | Like 'jarBuilder' but outputs the Netscape header before the cookie lines+netscapeJarBuilder :: CookieJar -> Builder+netscapeJarBuilder = jarBuilder' netscapeHeader+++netscapeHeader :: Builder+netscapeHeader = "# Netscape HTTP Cookie File\n"+++-- | Print a cookie jar in the Netscape/Mozilla format, with no header+jarBuilder :: CookieJar -> Builder+jarBuilder = foldMap ((<> "\n") . cookieBuilder) . destroyCookieJar+++-- | Like 'jarBuilder' but outputs a header before the cookie lines+jarBuilder' :: Builder -> CookieJar -> Builder+jarBuilder' header = (header <>) . jarBuilder+++-- | Writes a cookie jar to the given path in the Netscape/Mozilla format, with no header+writeJar :: FilePath -> CookieJar -> IO ()+writeJar fp = L.writeFile fp . toLazyByteString . jarBuilder+++-- | Like 'writeJar', but outputs a header before the cookie lines+writeJar' :: Builder -> FilePath -> CookieJar -> IO ()+writeJar' header fp =+  L.writeFile fp+    . toLazyByteString+    . jarBuilder'+      header+++-- | Like 'writeJar', but outputs the Netscape header before the cookie lines+writeNetscapeJar :: FilePath -> CookieJar -> IO ()+writeNetscapeJar = writeJar' netscapeHeader+++-- | Read a Cookie Jar from a file.+readJar :: FilePath -> IO (Either String CookieJar)+readJar = fmap parseCookieJar . B.readFile+++{- | Builder for one cookie; generates a single line in the Cookie Jar file format++the values of the following fields are not output, as the file format does+support them.++- 'cookie_creation_time'+- 'cookie_last_access_time'+- 'cookie_persistent'+-}+cookieBuilder :: Cookie -> Builder+cookieBuilder c =+  let+    httpOnly True = "#HttpOnly_"+    httpOnly False = mempty+    bool True = "TRUE"+    bool False = "FALSE"+    unixTime = integerDec . round . utcTimeToPOSIXSeconds+    tab = char7 '\t'+   in+    httpOnly (cookie_http_only c)+      <> byteString (cookie_domain c)+      <> tab+      <> bool (cookie_host_only c)+      <> tab+      <> byteString (cookie_path c)+      <> tab+      <> bool (cookie_secure_only c)+      <> tab+      <> unixTime (cookie_expiry_time c)+      <> tab+      <> byteString (cookie_name c)+      <> tab+      <> byteString (cookie_value c)
+ test/Cookie/JarSpec.hs view
@@ -0,0 +1,173 @@+{-# LANGUAGE OverloadedStrings #-}++{- |+Module      : Cookie.JarSpec+Copyright   : (c) 2023 Tim Emiola+Maintainer  : Tim Emiola <adetokunbo@emio.la>+SPDX-License-Identifier: BSD3+-}+module Cookie.JarSpec (spec) where++import Data.ByteString (ByteString)+import Data.ByteString.Builder (Builder, toLazyByteString)+import qualified Data.ByteString.Lazy as L+import Data.List (sortBy)+import Data.String (fromString)+import Data.Time (UTCTime)+import Data.Time.Clock.POSIX+  ( posixSecondsToUTCTime+  )+import Data.Word (Word16)+import Network.HTTP.Client+  ( Cookie (..)+  , CookieJar+  , compareCookies+  , createCookieJar+  , destroyCookieJar+  , equalCookie+  )+import System.IO.Temp (withSystemTempDirectory)+import Test.Hspec+import Test.QuickCheck+  ( Arbitrary (arbitrary)+  , Gen+  , Property+  , chooseInteger+  , forAll+  , listOf1+  , suchThat+  )+import Test.QuickCheck.Monadic (assert, monadicIO, pick, run)+import Web.Cookie.Jar+++spec :: Spec+spec = describe "Module Web.Cookie.Jar" $ do+  context "parsing with cookieParser" $ do+    context "and building with cookieBuilder" $ do+      it "should almost roundtrip" prop_almostRoundtripCookie++  context "parsing with cookieJarParser" $ do+    context "and building with jarBuilder" $ do+      it "should almost roundtrip" (prop_almostRoundtripCookieJar jarBuilder)++    context "and building netscapeJarBuilder" $ do+      it "should almost roundtrip" (prop_almostRoundtripCookieJar netscapeJarBuilder)++  context "when accessing persisted jars" $ around useTmp $ do+    context "writeJar then readJar" $ do+      it "should almost roundtrip" (prop_almostRoundtripSavedJars writeJar)+    context "writeNetscapeJar then readJar" $ do+      it "should almost roundtrip" (prop_almostRoundtripSavedJars writeNetscapeJar)+++useTmp :: (FilePath -> IO a) -> IO a+useTmp = withSystemTempDirectory "web-cookiejar"+++genJarWithPath :: Gen (FilePath, CookieJar)+genJarWithPath = do+  let mkPath i = "cookie-jar-" ++ i ++ ".txt"+  index <- mkPath . show <$> genWord16+  (,) index <$> genCookieJar+++prop_almostRoundtripSavedJars :: (FilePath -> CookieJar -> IO ()) -> FilePath -> Property+prop_almostRoundtripSavedJars writer root = monadicIO $ do+  (jarBase, jar) <- pick genJarWithPath+  let jarPath = root ++ "/" ++ jarBase++  -- this match is incomplete, that's ok, the test fails if it produces a Left+  Right jar' <- run $ do+    writer jarPath jar+    readJar jarPath+  assert $ almostEqJar jar jar'+++prop_almostRoundtripCookieJar :: (CookieJar -> Builder) -> Property+prop_almostRoundtripCookieJar toBuilder =+  forAll (cookieJarWithX toBuilder <$> genCookieJar) $ \(j, _txt, j') ->+    either (const False) (almostEqJar j) j'+++cookieJarWithX+  :: (CookieJar -> Builder) -> CookieJar -> (CookieJar, ByteString, Either String CookieJar)+cookieJarWithX toBuilder j =+  let txt = asByteString $ toBuilder j+   in (j, txt, parseCookieJar txt)+++genCookieJar :: Gen CookieJar+genCookieJar = createCookieJar <$> listOf1 genCookie+++almostEqJar :: CookieJar -> CookieJar -> Bool+almostEqJar jar1 jar2 =+  let+    cookiesOf = sortBy compareCookies . map fixup . destroyCookieJar+   in+    and $ zipWith equalCookie (cookiesOf jar1) (cookiesOf jar2)+++prop_almostRoundtripCookie :: Property+prop_almostRoundtripCookie =+  forAll (cookieWithLine <$> genCookie) $ \(c, line) ->+    either (const False) (almostEq c) $ parseOnly cookieParser line+++asByteString :: Builder -> ByteString+asByteString = L.toStrict . toLazyByteString+++cookieWithLine :: Cookie -> (Cookie, ByteString)+cookieWithLine c = (c, asByteString $ cookieBuilder c)+++genCookie :: Gen Cookie+genCookie = do+  (creation, expiry) <- genCreationAndExpiry+  Cookie+    <$> genWithSuffix "name_"+    <*> genWithSuffix "value_"+    <*> pure expiry+    <*> genWithSuffix "domain_"+    <*> genWithSuffix "path_"+    <*> pure creation+    <*> pure creation+    <*> arbitrary+    <*> arbitrary+    <*> arbitrary+    <*> arbitrary+++genCreationAndExpiry :: Gen (UTCTime, UTCTime)+genCreationAndExpiry = do+  creation <- genUTCTime+  expiry <- genUTCTime `suchThat` (> creation)+  pure (creation, expiry)+++almostEq :: Cookie -> Cookie -> Bool+almostEq c1 c2 = fixup c1 `equalCookie` fixup c2+++fixup :: Cookie -> Cookie+fixup c =+  let epoch = posixSecondsToUTCTime 0+   in c+        { cookie_persistent = True+        , cookie_last_access_time = epoch+        , cookie_creation_time = epoch+        }+++genWord16 :: Gen Word16+genWord16 = arbitrary+++genUTCTime :: Gen UTCTime+genUTCTime = posixSecondsToUTCTime . fromInteger <$> chooseInteger (1, 365 * 86400 * 20)+++genWithSuffix :: ByteString -> Gen ByteString+genWithSuffix bs = (bs <>) . fromString . show <$> genWord16
+ test/Spec.hs view
@@ -0,0 +1,22 @@+{-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}++module Main where++import qualified Cookie.JarSpec as Jar+import System.IO (+  BufferMode (..),+  hSetBuffering,+  stderr,+  stdout,+ )+import Test.Hspec+++main :: IO ()+main = do+  hSetBuffering stdout NoBuffering+  hSetBuffering stderr NoBuffering+  hspec $ do+    Jar.spec
+ web-cookiejar.cabal view
@@ -0,0 +1,59 @@+cabal-version:      3.0+name:               web-cookiejar+version:            0.1.0.0+synopsis:           Parsing/printing of persistent web cookies+description:+  A library that provides parsing and printing functions that read and write web+  cookies stored in the Netscape/Mozilla cookie jar file format; also the format+  used by curl.++  See the [README](https://github.com/adetokunbo/web-cookiejar?tab=readme-ov-file#web-cookiejar)+  for a simple usage example++license:            BSD-3-Clause+license-file:       LICENSE+author:             Tim Emiola+maintainer:         adetokunbo@emio.la+category:           Web+homepage:           https://github.com/adetokunbo/web-cookiejar#readme+bug-reports:+  https://github.com/adetokunbo/web-cookiejar/issues+tested-with:        GHC ==8.10.7 || ==9.0.2 || ==9.2.8 || ==9.4.8 || ==9.6.6 || ==9.8.2 || ==9.10.1+build-type:         Simple+extra-source-files:+  ChangeLog.md++source-repository head+  type:     git+  location: https://github.com/adetokunbo/web-cookiejar.git++library+  exposed-modules:  Web.Cookie.Jar+  hs-source-dirs:   src+  build-depends:+    , attoparsec           >=0.14.4 && <0.15+    , base                 >=4.10 && <5+    , bytestring           >=0.10.8 && <0.11 || >=0.11.3 && <0.13+    , http-client          >=0.5 && <0.8+    , time                 >=1.8 && <1.15++  default-language: Haskell2010+  ghc-options:      -Wall -Wincomplete-uni-patterns -Wpartial-fields -fwarn-tabs++test-suite test+  type:             exitcode-stdio-1.0+  main-is:          Spec.hs+  hs-source-dirs:   test+  other-modules:    Cookie.JarSpec+  default-language: Haskell2010+  ghc-options:      -threaded -rtsopts -with-rtsopts=-N -Wall -fwarn-tabs+  build-depends:+    , base+    , bytestring+    , hspec                >=2.7.0 && <2.12.0+    , http-client+    , QuickCheck           >= 2.13 && < 2.16+    , temporary            >= 1.2 && < 1.4+    , time+    , web-cookiejar+