packages feed

ftp-client-0.6.0.0: src/Network/FTP/Client.hs

{- |
Module      : Network.FTP.Client
Description : Transfer files over FTP and FTPS
Copyright   : Megan Robinson 2018-2019, Flipstone Technology Partners 2024-2026
License     : Public Domain
Stability   : experimental
Portability : POSIX
-}
module Network.FTP.Client
  ( -- * Main Entrypoints
    withFTP
  , withFTPS
  , withFTPSSettings

    -- * Control Commands
  , login
  , pasv
  , rename
  , dele
  , cwd
  , size
  , acct
  , mkd
  , rmd
  , pwd
  , quit

    -- * Data Commands
  , nlst
  , retr
  , list
  , stor
  , mlsd
  , mlst

    -- * Types
  , FTPCommand (..)
  , FTPResponse (..)
  , FTPMessage (..)
  , ResponseStatus (..)
  , MlsxResponse (..)
  , RTypeCode (..)
  , PortActivity (..)
  , ProtType (..)
  , Security (..)
  , TLSContext (..)
  , Handle (..)

    -- * TLS Commands
  , pbsz
  , prot

    -- * Exceptions
  , FTPException (..)

    -- * System Handle Creation
  , createSIOHandle
  , createTLSConnection
  , connectTLS

    -- * Handle Implementations
  , sIOHandleImpl
  , tlsHandleImpl

    -- * Lower Level Functions
  , sendCommand
  , sendCommandS
  , recvAll
  , sendAll
  , sendAllS
  , getLineResp
  , getAllLineResp
  , getLineRespMaybe
  , maxReplyLineLength
  , toNetworkAscii
  , requireTLSContext
  , getResponse
  , getResponseS
  , sendCommandLine
  , createSendDataCommand
  , createTLSSendDataCommand
  , PendingCompletion
  , newPendingCompletion
  , drainPendingCompletion
  , parseMlsxLine
  ) where

import Control.Arrow ((***))
import qualified Control.Exception as Exception
import Control.Monad ((<=<))
import qualified Control.Monad as Monad
import Control.Monad.Catch (MonadCatch, MonadMask)
import qualified Control.Monad.Catch as M
import qualified Control.Monad.IO.Class as MIO
import qualified Data.Attoparsec.ByteString.Char8 as AC
import qualified Data.Bits as Bits
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as C
import Data.ByteString.Lazy.Internal (defaultChunkSize)
import Data.Default.Class (def)
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.List (intercalate)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import qualified Network.Connection as Connection
import qualified Network.Socket as S
import qualified System.IO as SIO
import System.IO.Error (eofErrorType, isEOFError, mkIOError)

debugging :: Bool
debugging = False

debugPrint :: (Show a, MIO.MonadIO m) => a -> m ()
debugPrint s = Monad.when debugging (MIO.liftIO $ print s)

debugResponse :: (Show a, MIO.MonadIO m) => a -> m ()
debugResponse s = debugPrint $ "Recieved: " <> show s

{- | What a data connection needs in order to be protected the same way the
control connection is. The host is the one the control connection was opened
to, which is what the server's certificate is issued for -- deriving it from
the data socket instead names the local end and can never validate.
-}
data TLSContext = TLSContext
  { tlsContextSettings :: Connection.TLSSettings
  , tlsContextHost :: String
  , tlsContextPort :: Int
  }

-- Only TLS settings are threaded through the connection helpers today. If the
-- rest of the hardening lands -- ignoring the address in a PASV reply, a
-- timeout on the active-mode accept, a bound on data-channel line length --
-- those belong together with these as fields of one options record passed to
-- the with* functions, rather than as further positional parameters. That was
-- @qxjit's suggestion on https://github.com/flipstone/ftp-client/pull/1.

data Security = Clear | TLS TLSContext

-- | Can send and recieve a 'Data.ByteString.ByteString'.
data Handle = Handle
  { send :: ByteString -> IO ()
  , sendLine :: ByteString -> IO ()
  , recv :: Int -> IO ByteString
  , recvLine :: IO ByteString
  , security :: Security
  }

data FTPMessage = SingleLine ByteString | MultiLine [ByteString]
  deriving (Eq)

instance Show FTPMessage where
  show (SingleLine message) = C.unpack message
  show (MultiLine messages) = intercalate "\n" $ C.unpack <$> messages

-- | Response from an FTP command. ex "200 Welcome!"
data FTPResponse = FTPResponse
  { frStatus :: ResponseStatus
  -- ^ Interpretation of the first digit of an FTP response code
  , frCode :: Int
  -- ^ The three digit response code
  , frMessage :: FTPMessage
  -- ^ Text of the response
  }
  deriving (Eq)

instance Show FTPResponse where
  show fr = show (frCode fr) <> " " <> show (frMessage fr)

-- | First digit of an FTP response
data ResponseStatus
  = -- | 1
    Wait
  | -- | 2
    Success
  | -- | 3
    Continue
  | -- | 4
    FailureRetry
  | -- | 5
    Failure
  deriving (Show, Eq)

data FTPException
  = FailureRetryException FTPResponse
  | FailureException FTPResponse
  | UnsuccessfulException FTPResponse
  | BogusResponseFormatException FTPResponse
  | BadProtocolResponseException ByteString
  deriving (Show)

instance Exception.Exception FTPException

responseStatus :: ByteString -> ResponseStatus
responseStatus cbs =
  case C.uncons cbs of
    Just ('1', _) -> Wait
    Just ('2', _) -> Success
    Just ('3', _) -> Continue
    Just ('4', _) -> FailureRetry
    Just ('5', _) -> Failure
    _ -> Exception.throw $ BadProtocolResponseException cbs

data RTypeCode = TA | TI

serialzeRTypeCode :: RTypeCode -> String
serialzeRTypeCode TA = "A"
serialzeRTypeCode TI = "I"

data PortActivity = Active | Passive

data ProtType = P | C

-- | Commands according to the FTP specification
data FTPCommand
  = User String
  | Pass String
  | Acct String
  | RType RTypeCode
  | Retr String
  | Nlst [String]
  | Port S.HostAddress S.PortNumber
  | Stor String
  | List [String]
  | Rnfr String
  | Rnto String
  | Dele String
  | Size String
  | Mkd String
  | Rmd String
  | Pbsz Int
  | Prot ProtType
  | Mlsd String
  | Mlst String
  | Cwd String
  | Cdup
  | Ccc
  | Auth
  | Pwd
  | Abor
  | Pasv
  | Quit

instance Show FTPCommand where
  show = serializeCommand

formatPort :: S.HostAddress -> S.PortNumber -> String
formatPort ha pn =
  let
    (w1, w2, w3, w4) = S.hostAddressToTuple ha
    hn = show <$> [w1, w2, w3, w4]
    portParts = show <$> [pn `quot` 256, pn `mod` 256]
  in
    intercalate "," (hn <> portParts)

serializeCommand :: FTPCommand -> String
serializeCommand (User user) = "USER " <> user
serializeCommand (Pass pass) = "PASS " <> pass
serializeCommand (Acct account) = "ACCT " <> account
serializeCommand (RType rt) = "TYPE " <> serialzeRTypeCode rt
serializeCommand (Retr file) = "RETR " <> file
serializeCommand (Nlst []) = "NLST"
serializeCommand (Nlst args) = "NLST " <> unwords args
serializeCommand (Port ha pn) = "PORT " <> formatPort ha pn
serializeCommand (Stor loc) = "STOR " <> loc
serializeCommand (List []) = "LIST"
serializeCommand (List args) = "LIST " <> unwords args
serializeCommand (Rnfr from) = "RNFR " <> from
serializeCommand (Rnto to) = "RNTO " <> to
serializeCommand (Dele file) = "DELE " <> file
serializeCommand (Size file) = "SIZE " <> file
serializeCommand (Mkd dir) = "MKD " <> dir
serializeCommand (Rmd dir) = "RMD " <> dir
serializeCommand (Pbsz buf) = "PBSZ " <> show buf
serializeCommand (Prot P) = "PROT P"
serializeCommand (Prot C) = "PROT C"
serializeCommand (Mlsd path) = "MLSD " <> path
serializeCommand (Mlst path) = "MLST " <> path
serializeCommand (Cwd dir) = "CWD " <> dir
serializeCommand Cdup = "CDUP"
serializeCommand Ccc = "CCC"
serializeCommand Auth = "AUTH TLS"
serializeCommand Pwd = "PWD"
serializeCommand Abor = "ABOR"
serializeCommand Pasv = "PASV"
serializeCommand Quit = "QUIT"

{- | Cap on one reply line. RFC 959 replies are short; this exists so a server
that never sends a newline cannot make us buffer without limit. It applies to
both control connections: 'Connection.connectionGetLine' takes it directly, and
'hGetLineBounded' applies it to a clear handle. Exceeding it raises
'Connection.LineTooLong', which is not an 'IOError' and so is not swallowed by
the end-of-input handling elsewhere in this module.
-}
maxReplyLineLength :: Int
maxReplyLineLength = 65536

{- | 'C.hGetLine' with a bound, for handles that 'Network.Connection' is not
managing. Reads a byte at a time rather than in chunks because 'recv' shares
the handle: anything taken past the newline would be stolen from the data the
caller asks for next. The handle is buffered, so this is not a syscall per
byte, and reply lines are short.

End of input is reported the way 'C.hGetLine' reports it, because
'getLineRespMaybe' relies on the distinction: throw when nothing has been read
yet, hand back the partial line when something has.
-}
hGetLineBounded :: Int -> SIO.Handle -> IO ByteString
hGetLineBounded limit h =
  let
    collect :: Int -> [ByteString] -> IO ByteString
    collect remaining acc
      | remaining <= 0 = Exception.throwIO Connection.LineTooLong
      | otherwise = do
          byte <- B.hGet h 1
          if B.null byte
            then
              if null acc
                then
                  Exception.throwIO $
                    mkIOError eofErrorType "hGetLineBounded" (Just h) Nothing
                else return $ B.concat (reverse acc)
            else
              if byte == C.singleton '\n'
                then return $ B.concat (reverse acc)
                else collect (remaining - 1) (byte : acc)
  in
    collect limit []

stripCLRF :: ByteString -> ByteString
stripCLRF = C.takeWhile $ (&&) <$> (/= '\r') <*> (/= '\n')

-- | Get a line from the server
getLineResp :: Handle -> IO ByteString
getLineResp h = stripCLRF <$> recvLine h

{- | Get a line from the server, returning 'Nothing' once the stream is
exhausted. A blank line and end of input are different things: 'recvLine'
signals end of input by throwing, and an empty 'ByteString' is a legitimate
line of reply text.
-}
getLineRespMaybe :: Handle -> IO (Maybe ByteString)
getLineRespMaybe h =
  (Just <$> getLineResp h) `M.catchIOError` \e ->
    if isEOFError e
      then return Nothing
      else ioError e

{- | Get a full response from the server
Used in 'sendCommand'
-}
getResponse :: MIO.MonadIO m => Handle -> m FTPResponse
getResponse h = do
  line <- MIO.liftIO $ getLineResp h
  let
    (code, rest) = C.splitAt 3 line
  -- A response must open with a three digit code. Checking that up front keeps
  -- the 'C.uncons' below and the 'read' further down from being partial.
  Monad.when (C.length code < 3 || not (C.all AC.isDigit code)) $
    MIO.liftIO $
      Exception.throwIO $
        BadProtocolResponseException line
  message <- case C.uncons rest of
    Just ('-', _) -> MultiLine <$> loopMultiLine h code [line]
    _ -> return $ SingleLine line
  let
    codeDroppedMessage = case message of
      SingleLine singleMessage -> SingleLine $ C.drop 4 singleMessage
      MultiLine [] -> MultiLine []
      MultiLine (firstMessage : messages) ->
        MultiLine $ C.drop 4 firstMessage : messages
  let
    response =
      FTPResponse
        (responseStatus code)
        (read $ C.unpack code)
        codeDroppedMessage
  case frStatus response of
    FailureRetry -> MIO.liftIO $ Exception.throwIO $ FailureRetryException response
    Failure -> MIO.liftIO $ Exception.throwIO $ FailureException response
    _ -> return response

loopMultiLine ::
  MIO.MonadIO m =>
  Handle ->
  ByteString ->
  [ByteString] ->
  m [ByteString]
loopMultiLine h code priorLines = do
  mNextLine <- MIO.liftIO $ getLineRespMaybe h
  case mNextLine of
    -- The server hung up before sending the terminating line. Stop rather
    -- than looping forever, but treat the reply as bad rather than
    -- returning it: what was collected is a fragment, and handing it back
    -- would turn a truncated reply into a well formed one. A cut off "220-"
    -- greeting would read as a successful 220 and let 'withFTP' carry on
    -- against a control connection that is already gone.
    --
    -- This is end of input, not a blank line. RFC 959 lets the intermediate
    -- lines of a multiline reply hold arbitrary text, blank lines included,
    -- so a blank line has to be kept and the loop has to continue past it.
    Nothing ->
      MIO.liftIO $
        Exception.throwIO $
          BadProtocolResponseException $
            C.intercalate "\n" priorLines
    Just nextLine -> do
      -- RFC 959 (https://datatracker.ietf.org/doc/html/rfc959#page-36) ends a
      -- multiline reply with the code followed by a space, and continues it
      -- with the code followed by a hyphen. The bare code is accepted too,
      -- for servers that omit the trailing space on an empty final line.
      let
        newLines = priorLines <> [C.dropWhile (== ' ') nextLine]
        isLastLine =
          nextLine == code
            || C.isPrefixOf (code <> " ") nextLine
      if isLastLine
        then return newLines
        else loopMultiLine h code newLines

ensureSuccess :: MIO.MonadIO m => FTPResponse -> m FTPResponse
ensureSuccess resp =
  case frStatus resp of
    Success -> return resp
    _ -> MIO.liftIO $ Exception.throwIO $ UnsuccessfulException resp

getResponseS :: MIO.MonadIO m => Handle -> m FTPResponse
getResponseS = ensureSuccess <=< getResponse

sendCommandLine :: MIO.MonadIO m => Handle -> ByteString -> m ()
sendCommandLine h = MIO.liftIO . send h . (<> "\r\n")

{- | Send a command to the server and get a response back.
Some commands use a data 'Handle', and their data is not returned here.
-}
sendCommand :: MIO.MonadIO m => Handle -> FTPCommand -> m FTPResponse
sendCommand h fc = do
  let
    command = serializeCommand fc
  debugPrint $ "Sending: " <> command
  sendCommandLine h $ C.pack command
  resp <- getResponse h
  debugResponse resp
  return resp

sendCommandS :: MIO.MonadIO m => Handle -> FTPCommand -> m FTPResponse
sendCommandS h fc = sendCommand h fc >>= ensureSuccess

{- | Equvalent to

> mapM . sendCommand
-}
sendAll :: MIO.MonadIO m => Handle -> [FTPCommand] -> m [FTPResponse]
sendAll = mapM . sendCommand

{- | Equvalent to

> mapM . sendCommandS
-}
sendAllS :: MIO.MonadIO m => Handle -> [FTPCommand] -> m [FTPResponse]
sendAllS = mapM . sendCommandS

-- Control connection

createSocket ::
  MIO.MonadIO m =>
  Maybe String ->
  Int ->
  S.AddrInfo ->
  m (S.Socket, S.AddrInfo)
createSocket host portNum hints = do
  addr <- MIO.liftIO $ do
    a : _ <- S.getAddrInfo (Just hints) host (Just $ show portNum)
    return a
  debugPrint $ "Addr: " <> show addr
  sock <-
    MIO.liftIO $
      S.socket
        (S.addrFamily addr)
        (S.addrSocketType addr)
        (S.addrProtocol addr)
  return (sock, addr)

withSocketPassive ::
  (MIO.MonadIO m, MonadMask m) =>
  String ->
  Int ->
  (S.Socket -> m a) ->
  m a
withSocketPassive host portNum f = do
  let
    hints =
      S.defaultHints
        { S.addrSocketType = S.Stream
        }
  -- bracketOnError, not bracket: on success this socket is handed to
  -- socketToHandle, which takes ownership of the descriptor, so closing it here
  -- as well would be wrong. Contrast withSocketActive above.
  M.bracketOnError
    (createSocket (Just host) portNum hints)
    (MIO.liftIO . S.close . fst)
    ( \(sock, addr) -> do
        debugPrint ("Connecting" :: String)
        MIO.liftIO $ S.connect sock (S.addrAddress addr)
        debugPrint ("Connected" :: String)
        f sock
    )

withSocketActive :: (MIO.MonadIO m, MonadMask m) => (S.Socket -> m a) -> m a
withSocketActive f = do
  let
    hints =
      S.defaultHints
        { S.addrSocketType = S.Stream
        , S.addrFlags = [S.AI_PASSIVE]
        }
  -- bracket, not bracketOnError: in active mode acceptData returns a *new*
  -- socket and only that one is converted to a Handle, so ownership of this
  -- listening socket is never transferred and it must be closed on the success
  -- path too. The passive helper below is the opposite case and deliberately
  -- differs.
  M.bracket
    (createSocket Nothing 0 hints)
    (MIO.liftIO . S.close . fst)
    ( \(sock, addr) -> do
        debugPrint ("Binding" :: String)
        MIO.liftIO $ S.bind sock (S.addrAddress addr)
        MIO.liftIO $ S.listen sock 1
        debugPrint ("Listening" :: String)
        f sock
    )

createSIOHandle :: (MIO.MonadIO m, MonadMask m) => String -> Int -> m SIO.Handle
createSIOHandle host portNum =
  withSocketPassive host portNum $
    MIO.liftIO . flip S.socketToHandle SIO.ReadWriteMode

sIOHandleImpl :: SIO.Handle -> Handle
sIOHandleImpl h =
  Handle
    { send = C.hPut h
    , -- RFC 959 TYPE A data uses CRLF, not a bare LF
      sendLine = \s -> C.hPut h (s <> "\r\n")
    , recv = C.hGetSome h
    , recvLine = hGetLineBounded maxReplyLineLength h
    , security = Clear
    }

withSIOHandle ::
  (MIO.MonadIO m, MonadMask m) =>
  String ->
  Int ->
  (Handle -> m a) ->
  m a
withSIOHandle host portNum f =
  M.bracket
    (MIO.liftIO $ createSIOHandle host portNum)
    (MIO.liftIO . SIO.hClose)
    (f . sIOHandleImpl)

{- | Takes a host name and port. A handle for interacting with the server
will be returned in a callback.

@
withFTP "ftp.server.com" 21 $ \h welcome -> do
    print welcome
    login h "username" "password"
    print =<< nlst h []
@
-}
withFTP ::
  (MIO.MonadIO m, MonadMask m) =>
  String ->
  Int ->
  (Handle -> FTPResponse -> m a) ->
  m a
withFTP host portNum f = withSIOHandle host portNum $ \h -> do
  resp <- getResponse h
  f h resp

-- Data connection

withDataSocketPasv ::
  (MIO.MonadIO m, MonadMask m) =>
  Handle ->
  (S.Socket -> m a) ->
  m a
withDataSocketPasv h f = do
  (host, portNum) <- pasv h
  debugPrint $ "Host: " <> host
  debugPrint $ "Port: " <> show portNum
  withSocketPassive host portNum f

withDataSocketActive ::
  (MIO.MonadIO m, MonadMask m) =>
  Handle ->
  (S.Socket -> m a) ->
  m a
withDataSocketActive h f = withSocketActive $ \socket -> do
  (sPort, sHost) <- MIO.liftIO $ do
    (S.SockAddrInet p hostAddr) <- S.getSocketName socket
    return (p, hostAddr)
  _ <- port h sHost sPort
  f socket

-- | Open a socket that can be used for data transfers
withDataSocket ::
  (MIO.MonadIO m, MonadMask m) =>
  PortActivity ->
  Handle ->
  (S.Socket -> m a) ->
  m a
withDataSocket Active = withDataSocketActive
withDataSocket Passive = withDataSocketPasv

acceptData :: MIO.MonadIO m => PortActivity -> S.Socket -> m S.Socket
acceptData Passive = return
acceptData Active = return . fst <=< MIO.liftIO . S.accept

-- Response to data commands should be 150 but apparently
-- some servers will respond with 200 before 150 so just ignore it
ensureSucessfulData :: MIO.MonadIO m => Handle -> FTPResponse -> m ()
ensureSucessfulData h resp = do
  resp' <- case frStatus resp of
    Success -> do
      newResp <- getResponse h
      debugResponse newResp
      return newResp
    _ -> return resp
  MIO.liftIO $
    Monad.when (frStatus resp' /= Wait) $
      Exception.throwIO $
        UnsuccessfulException resp

{- | Send setup commands to the server and
create a data 'System.IO.Handle'
-}
createSendDataCommand ::
  (MIO.MonadIO m, MonadMask m) =>
  Handle ->
  PortActivity ->
  PendingCompletion ->
  FTPCommand ->
  m SIO.Handle
createSendDataCommand h pa pending cmd = withDataSocket pa h $ \socket -> do
  resp <- sendCommand h cmd
  ensureSucessfulData h resp
  -- Past this point the server has accepted the transfer, so a completion
  -- reply is owed to us however the rest of it goes.
  markCompletionPending pending
  acceptedSock <- acceptData pa socket
  MIO.liftIO $ S.socketToHandle acceptedSock SIO.ReadWriteMode

-- | Provides a data 'Handle' in a callback for a command
withDataCommand ::
  (MIO.MonadIO m, MonadMask m) =>
  Handle ->
  PortActivity ->
  RTypeCode ->
  FTPCommand ->
  (Handle -> m a) ->
  m a
withDataCommand ch pa code cmd f = do
  _ <- sendCommandS ch $ RType code
  pending <- newPendingCompletion
  x <-
    M.bracket
      (createSendDataCommand ch pa pending cmd)
      (MIO.liftIO . SIO.hClose)
      (f . sIOHandleImpl)
      `M.onException` drainPendingCompletion ch pending
  resp <- getResponse ch
  debugResponse resp
  return x

-- | Recieve data and interpret it linewise
getAllLineResp :: (MIO.MonadIO m, MonadCatch m) => Handle -> m ByteString
getAllLineResp h =
  let
    collect :: MIO.MonadIO n => [ByteString] -> n ByteString
    collect ret = do
      mLine <- MIO.liftIO $ getLineRespMaybe h
      case mLine of
        Nothing -> return $ C.intercalate (C.pack "\n") ret
        Just line -> collect (ret <> [line])
  in
    collect []

-- | Recieve all data and return it as a 'Data.ByteString.ByteString'
recvAll :: (MIO.MonadIO m, MonadCatch m) => Handle -> m ByteString
recvAll h =
  let
    collect :: (MIO.MonadIO n, MonadCatch n) => ByteString -> n ByteString
    collect bs =
      ( do
          -- No handler here on purpose. recv returns "" at end of data, so
          -- catching IOErrors would only turn a reset or timed-out connection
          -- into a short read that looks like a complete one.
          chunk <- MIO.liftIO $ recv h defaultChunkSize
          if C.null chunk
            then return bs
            else collect $ bs <> chunk
      )
  in
    collect ""

-- TLS connection

{- | Wrap an existing handle in TLS. The settings are supplied by the caller;
'def' validates the server's certificate chain and host name. Passing settings
with 'Connection.settingDisableCertificateValidation' set gives an encrypted
but unauthenticated connection, which any on-path attacker can read and rewrite.
-}
connectTLS ::
  MIO.MonadIO m =>
  Connection.TLSSettings ->
  SIO.Handle ->
  String ->
  Int ->
  m Connection.Connection
connectTLS settings h host portNum = do
  context <- MIO.liftIO Connection.initConnectionContext
  let
    connectionParams =
      Connection.ConnectionParams
        { Connection.connectionHostname = host
        , Connection.connectionPort = toEnum . fromEnum $ portNum
        , Connection.connectionUseSecure = Just settings
        , Connection.connectionUseSocks = Nothing
        }
  MIO.liftIO $ Connection.connectFromHandle context h connectionParams

createTLSConnection ::
  (MIO.MonadIO m, MonadMask m) =>
  Connection.TLSSettings ->
  String ->
  Int ->
  m (FTPResponse, Connection.Connection)
createTLSConnection settings host portNum =
  -- Without this the socket leaks whenever the greeting is a refusal, AUTH TLS
  -- is rejected, or the handshake fails. This is the acquire action of
  -- withTLSHandle's bracket, so its release would never run.
  M.bracketOnError
    (createSIOHandle host portNum)
    (MIO.liftIO . SIO.hClose)
    ( \h -> do
        let
          insecureH = sIOHandleImpl h
        resp <- getResponse insecureH
        _ <- sendCommandS insecureH Auth
        conn <- connectTLS settings h host portNum
        return (resp, conn)
    )

tlsHandleImpl :: TLSContext -> Connection.Connection -> Handle
tlsHandleImpl tlsContext c =
  Handle
    { send = Connection.connectionPut c
    , -- RFC 959 TYPE A data uses CRLF, not a bare LF
      sendLine = Connection.connectionPut c . (<> "\r\n")
    , recv = Connection.connectionGet c
    , recvLine = Connection.connectionGetLine maxReplyLineLength c
    , security = TLS tlsContext
    }

withTLSHandle ::
  (MonadMask m, MIO.MonadIO m) =>
  Connection.TLSSettings ->
  String ->
  Int ->
  (Handle -> FTPResponse -> m a) ->
  m a
withTLSHandle settings host portNum f =
  let
    tlsContext =
      TLSContext
        { tlsContextSettings = settings
        , tlsContextHost = host
        , tlsContextPort = portNum
        }
  in
    M.bracket
      (createTLSConnection settings host portNum)
      (MIO.liftIO . Connection.connectionClose . snd)
      (\(resp, conn) -> f (tlsHandleImpl tlsContext conn) resp)

{- | Takes a host name and port. A handle for interacting with the server
will be returned in a callback. The connection is protected with TLS and the
server's certificate chain and host name are verified, so a failure to validate
aborts the connection.

@
withFTPS "ftps.server.com" 21 $ \h welcome -> do
    print welcome
    login h "username" "password"
    print =<< nlst h []
@

Use 'withFTPSSettings' if you need to talk to a server whose certificate cannot
be validated.
-}
withFTPS ::
  (MonadMask m, MIO.MonadIO m) =>
  String ->
  Int ->
  (Handle -> FTPResponse -> m a) ->
  m a
withFTPS = withTLSHandle def

{- | As 'withFTPS', but with caller supplied TLS settings.

Setting 'Connection.settingDisableCertificateValidation' accepts any
certificate, including one an attacker generated. The connection is then
encrypted but not authenticated: anyone on the network path can read the
credentials sent by 'login' and alter transferred data. Only do this when you
have another way to establish that the peer is who it claims to be.

@
let insecure = 'def' { 'Connection.settingDisableCertificateValidation' = True }
withFTPSSettings insecure "ftps.server.com" 21 $ \h welcome -> do
    print welcome
@
-}
withFTPSSettings ::
  (MonadMask m, MIO.MonadIO m) =>
  Connection.TLSSettings ->
  String ->
  Int ->
  (Handle -> FTPResponse -> m a) ->
  m a
withFTPSSettings = withTLSHandle

-- TLS data connection

{- | The TLS details of a control connection, for reproducing them on a data
connection.
-}
requireTLSContext :: MIO.MonadIO m => Handle -> m TLSContext
requireTLSContext h =
  case security h of
    TLS ctx -> return ctx
    Clear ->
      MIO.liftIO . Exception.throwIO . BadProtocolResponseException $
        C.pack "cannot open a TLS data connection over a clear control connection"

{- | Read the reply that terminates a data transfer, discarding any failure.

Used on the paths where the transfer did not complete normally. The reply still
has to be taken off the control connection: left there it becomes the answer to
whichever command is sent next, and every reply after that belongs to the
previous command for the rest of the session. Failures are swallowed so this
cannot mask the exception that brought us here.
-}
drainDataResponse :: (MIO.MonadIO m, MonadCatch m) => Handle -> m ()
drainDataResponse ch =
  Monad.void (getResponse ch) `M.catchAll` (\_ -> return ())

{- | Whether the server has accepted the preliminary reply to a transfer
command, and so whether a completion reply is on its way.

A transfer that is rejected outright -- @PBSZ@, @PROT@, @PASV@ or the transfer
command itself -- fails with its error reply already consumed by
'ensureSucessfulData', and nothing further is coming. Draining then waits for a
reply the server will never send, which blocks until it gives up on the
connection. Draining is only correct once this says a reply is pending.
-}
newtype PendingCompletion = PendingCompletion (IORef Bool)

newPendingCompletion :: MIO.MonadIO m => m PendingCompletion
newPendingCompletion =
  MIO.liftIO $ PendingCompletion <$> newIORef False

markCompletionPending :: MIO.MonadIO m => PendingCompletion -> m ()
markCompletionPending (PendingCompletion ref) =
  MIO.liftIO $ writeIORef ref True

{- | 'drainDataResponse', but only when a completion reply is actually pending.

This deliberately stays outside the bracket that owns the data connection. The
server sends the completion reply once that connection has closed, so draining
before the release action has run would block on a reply that is waiting on us.
-}
drainPendingCompletion ::
  (MIO.MonadIO m, MonadCatch m) =>
  Handle ->
  PendingCompletion ->
  m ()
drainPendingCompletion ch (PendingCompletion ref) = do
  pending <- MIO.liftIO $ readIORef ref
  Monad.when pending $ drainDataResponse ch

{- | Send setup commands to the server and
create a data TLS connection
-}
createTLSSendDataCommand ::
  (MIO.MonadIO m, MonadMask m) =>
  Handle ->
  PortActivity ->
  PendingCompletion ->
  FTPCommand ->
  m Connection.Connection
createTLSSendDataCommand ch pa pending cmd = do
  tlsContext <- requireTLSContext ch
  _ <- sendAllS ch [Pbsz 0, Prot P]
  withDataSocket pa ch $ \socket -> do
    resp <- sendCommand ch cmd
    ensureSucessfulData ch resp
    -- Past this point the server has accepted the transfer, so a completion
    -- reply is owed to us however the rest of it goes -- a rejected
    -- certificate in the handshake below included.
    markCompletionPending pending
    acceptedSock <- acceptData pa socket
    -- socketToHandle invalidates the socket, so the enclosing bracketOnError's
    -- close becomes a no-op from here on; protect the handle separately or a
    -- failed handshake leaks the descriptor.
    M.bracketOnError
      (MIO.liftIO $ S.socketToHandle acceptedSock SIO.ReadWriteMode)
      (MIO.liftIO . SIO.hClose)
      ( \h ->
          -- Authenticate against the host the control connection was opened to.
          -- getSocketName here would name the local end of the data socket,
          -- which no server certificate can ever match.
          MIO.liftIO $
            connectTLS
              (tlsContextSettings tlsContext)
              h
              (tlsContextHost tlsContext)
              (tlsContextPort tlsContext)
      )

withTLSDataCommand ::
  (MIO.MonadIO m, MonadMask m) =>
  Handle ->
  PortActivity ->
  RTypeCode ->
  FTPCommand ->
  (Handle -> m a) ->
  m a
withTLSDataCommand ch pa code cmd f = do
  tlsContext <- requireTLSContext ch
  _ <- sendCommandS ch $ RType code
  pending <- newPendingCompletion
  x <-
    M.bracket
      (createTLSSendDataCommand ch pa pending cmd)
      (MIO.liftIO . Connection.connectionClose)
      (f . tlsHandleImpl tlsContext)
      `M.onException` drainPendingCompletion ch pending
  resp <- getResponse ch
  debugPrint $ "Recieved: " <> show resp
  return x

parseResponse :: MIO.MonadIO m => FTPResponse -> AC.Parser a -> m a
parseResponse resp p =
  let
    parsableMessage = case frMessage resp of
      SingleLine message -> message
      MultiLine messages -> C.intercalate "\n" messages
  in
    case AC.parseOnly p parsableMessage of
      Right x -> return x
      Left _ ->
        MIO.liftIO $
          Exception.throwIO $
            BadProtocolResponseException parsableMessage

ensureCode :: MIO.MonadIO m => FTPResponse -> Int -> m ()
ensureCode resp code =
  MIO.liftIO $
    Monad.when (frCode resp /= code) $
      MIO.liftIO $
        Exception.throwIO $
          UnsuccessfulException resp

parse227 :: AC.Parser (String, Int)
parse227 = do
  _ <- AC.skipWhile (/= '(') *> AC.char '('
  [h1, h2, h3, h4, p1, p2] <- AC.many1 AC.digit `AC.sepBy` AC.char ','
  let
    host = intercalate "." [h1, h2, h3, h4]
    highBits = read p1
    lowBits = read p2
    portNum = (highBits `Bits.shift` 8) + lowBits
  return (host, portNum)

parse257 :: AC.Parser String
parse257 = do
  _ <- AC.char '"'
  C.unpack <$> AC.takeTill (== '"')

-- Control commands

login :: MIO.MonadIO m => Handle -> String -> String -> m FTPResponse
login h user pass = do
  resp <- last <$> sendAll h [User user, Pass pass]
  ensureSuccess resp

pasv :: MIO.MonadIO m => Handle -> m (String, Int)
pasv h = do
  resp <- sendCommandS h Pasv
  ensureCode resp 227
  parseResponse resp parse227

port :: MIO.MonadIO m => Handle -> S.HostAddress -> S.PortNumber -> m FTPResponse
port h ha pn = sendCommandS h (Port ha pn)

acct :: MIO.MonadIO m => Handle -> String -> m FTPResponse
acct h pass = sendCommandS h (Acct pass)

rename :: MIO.MonadIO m => Handle -> String -> String -> m FTPResponse
rename h from to = do
  res <- sendCommand h (Rnfr from)
  case frStatus res of
    Continue -> sendCommandS h (Rnto to)
    _ -> return res

dele :: MIO.MonadIO m => Handle -> String -> m FTPResponse
dele h file = sendCommandS h (Dele file)

cwd :: MIO.MonadIO m => Handle -> String -> m FTPResponse
cwd h dir =
  sendCommandS h $
    if dir == ".."
      then Cdup
      else Cwd dir

size :: MIO.MonadIO m => Handle -> String -> m Int
size h file = do
  resp <- sendCommandS h (Size file)
  ensureCode resp 213
  return $ case frMessage resp of
    SingleLine message -> read . C.unpack $ message
    MultiLine _ -> 0

mkd :: MIO.MonadIO m => Handle -> String -> m String
mkd h dir = do
  resp <- sendCommandS h (Mkd dir)
  ensureCode resp 257
  parseResponse resp parse257

rmd :: MIO.MonadIO m => Handle -> String -> m FTPResponse
rmd h dir = sendCommandS h (Rmd dir)

pwd :: MIO.MonadIO m => Handle -> m String
pwd h = do
  resp <- sendCommandS h Pwd
  ensureCode resp 257
  parseResponse resp parse257

quit :: MIO.MonadIO m => Handle -> m FTPResponse
quit h = sendCommandS h Quit

mlst :: (MIO.MonadIO m, MonadMask m) => Handle -> String -> m MlsxResponse
mlst h path = do
  resp <- sendCommandS h (Mlst path)
  case frMessage resp of
    SingleLine message -> return $ parseMlsxLine message
    MultiLine messages ->
      if length messages >= 2
        then return $ parseMlsxLine $ messages !! 1
        else MIO.liftIO $ Exception.throwIO $ BogusResponseFormatException resp

-- TLS commands

pbsz :: MIO.MonadIO m => Handle -> Int -> m FTPResponse
pbsz h = sendCommandS h . Pbsz

prot :: MIO.MonadIO m => Handle -> ProtType -> m FTPResponse
prot h = sendCommandS h . Prot

-- CCC and AUTH deliberately have no wrappers. CCC tells the server to drop to
-- cleartext, but there is no way to downgrade our side of a
-- crypton-connection, so the control connection would desynchronise and
-- 'security' could not be corrected to match. AUTH on its own tells the server
-- to expect a handshake that never happens; 'createTLSConnection' is the only
-- sequence that issues it correctly. Both remain reachable as 'FTPCommand'
-- constructors for anyone who needs to drive them by hand.

-- Data commands

{- | Rewrite line endings for a TYPE A transfer. RFC 959 specifies NVT-ASCII,
whose terminator is CRLF. Input already using CRLF is left alone rather than
having its CR doubled, and input with no final terminator does not gain one.
-}
toNetworkAscii :: ByteString -> ByteString
toNetworkAscii =
  let
    dropTrailingCR piece =
      if not (C.null piece) && C.last piece == '\r'
        then C.init piece
        else piece
  in
    C.intercalate (C.pack "\r\n") . fmap dropTrailingCR . C.split '\n'

sendType :: MIO.MonadIO m => RTypeCode -> ByteString -> Handle -> m ()
sendType TA dat h = MIO.liftIO . send h $ toNetworkAscii dat
sendType TI dat h = MIO.liftIO $ send h dat

withDataCommandSecurity ::
  (MIO.MonadIO m, MonadMask m) =>
  Handle ->
  PortActivity ->
  RTypeCode ->
  FTPCommand ->
  (Handle -> m a) ->
  m a
withDataCommandSecurity h =
  case security h of
    Clear -> withDataCommand h
    TLS _ -> withTLSDataCommand h

nlst :: (MIO.MonadIO m, MonadMask m) => Handle -> [String] -> m ByteString
nlst h args = withDataCommandSecurity h Passive TA (Nlst args) getAllLineResp

retr :: (MIO.MonadIO m, MonadMask m) => Handle -> String -> m ByteString
retr h path = withDataCommandSecurity h Passive TI (Retr path) recvAll

list :: (MIO.MonadIO m, MonadMask m) => Handle -> [String] -> m ByteString
list h args = withDataCommandSecurity h Passive TA (List args) getAllLineResp

stor ::
  (MIO.MonadIO m, MonadMask m) =>
  Handle ->
  String ->
  B.ByteString ->
  RTypeCode ->
  m ()
stor h loc dat rtype =
  withDataCommandSecurity h Passive rtype (Stor loc) $ sendType rtype dat

data MlsxResponse = MlsxResponse
  { mrFilename :: String
  , mrFacts :: Map String String
  }
  deriving (Show)

splitApart :: Char -> ByteString -> (ByteString, ByteString)
splitApart on s =
  let
    (x0, x1) = C.break (== on) s
  in
    (x0, C.drop 1 x1)

parseMlsxLine :: ByteString -> MlsxResponse
parseMlsxLine line =
  let
    (factLine, filename) = splitApart ' ' line
    bFacts = splitApart '=' <$> C.split ';' factLine
    facts =
      Map.fromList $
        filter (not . null . fst) $
          Monad.join (***) C.unpack <$> bFacts
  in
    MlsxResponse (C.unpack filename) facts

getMlsxResponse :: (MIO.MonadIO m, MonadCatch m) => Handle -> m [MlsxResponse]
getMlsxResponse h =
  let
    collect :: MIO.MonadIO n => [MlsxResponse] -> n [MlsxResponse]
    collect ret = do
      mLine <- MIO.liftIO $ getLineRespMaybe h
      case mLine of
        Nothing -> return ret
        Just line ->
          collect $
            if C.null line
              then ret
              else parseMlsxLine line : ret
  in
    collect []

mlsd :: (MIO.MonadIO m, MonadMask m) => Handle -> String -> m [MlsxResponse]
mlsd h path = withDataCommandSecurity h Passive TA (Mlsd path) getMlsxResponse