packages feed

pqi-native 1.0.1.3 → 1.0.1.4

raw patch · 5 files changed

+168/−36 lines, 5 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

Files

CHANGELOG.md view
@@ -1,3 +1,11 @@+# v1.0.1.4++## Fixes++- Fixed missing support for connecting over a Unix-domain socket (#6). A `host` value that looks like an absolute path (e.g. `host=/var/run/postgresql`) names a socket directory rather than a TCP host, and the connection is made to a `.s.PGSQL.<port>` unix-domain socket in that directory, mirroring libpq's rule for the `host` conninfo parameter. A conninfo with no `host` (or an empty one) now defaults the way libpq itself does: the `PGHOST` environment variable if set and non-empty, otherwise a Unix-domain socket in `/tmp` on Unix-like systems (see `Pqi.Native.Connection.defaultUnixSocketDir`'s Haddock for how a distribution that compiles its own `libpq` with a different default, e.g. Fedora's `/run/postgresql`, can match it via `cabal.project` - no source patch needed), or `localhost` on Windows, unchanged. Connect-failure and handshake-failure messages now describe the socket path rather than a host/port pair when connecting this way.++  No privilege-elevation guard is applied to reading `PGHOST` (or the existing `PGUSER` lookup): libpq itself reads these with plain `getenv()`, with no `secure_getenv`/`geteuid`-vs-`getuid` check anywhere in `fe-connect.c` - responsibility for scrubbing the environment before opening a database connection is on any setuid/setgid caller, exactly as it is for libpq.+ # v1.0.1.3  ## Fixes
README.md view
@@ -53,3 +53,9 @@ Authentication: **trust**, **MD5**, and **SCRAM-SHA-256** are implemented. SCRAM is verified against a password-auth PostgreSQL 17 container (which defaults to `scram-sha-256`).++Transport: plain TCP and Unix-domain sockets. A `host` value that's an+absolute path names a socket directory instead of a TCP host, and omitting+`host` altogether defaults the way libpq itself does: `PGHOST` if set,+otherwise a Unix-domain socket in `/tmp` on Unix-like systems, or `localhost`+on Windows.
pqi-native.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: pqi-native-version: 1.0.1.3+version: 1.0.1.4 category: Database, PostgreSQL synopsis: Native (pure-Haskell) adapter for pqi description:
src/library/Pqi/Native/Connection.hs view
@@ -56,16 +56,16 @@ -- format. Unquoted key=value values only; URI values are percent-decoded. -- @.pgpass@ is not supported. ----- The @dfltUser@ argument is the already-resolved default user (see--- 'resolveDefaultUser'), used whenever the conninfo omits @user@. It is passed--- in explicitly because resolving it is the one IO effect this otherwise-pure--- parser needs.-parseConnInfo :: ByteString -> ByteString -> ConnInfo-parseConnInfo raw dfltUser =+-- The @dfltUser@\/@dfltHost@ arguments are the already-resolved defaults (see+-- 'resolveDefaultUser'\/'resolveDefaultHost'), used whenever the conninfo+-- omits @user@\/@host@ respectively. They are passed in explicitly because+-- resolving them is the only IO this otherwise-pure parser needs.+parseConnInfo :: ByteString -> ByteString -> ByteString -> ConnInfo+parseConnInfo raw dfltUser dfltHost =   if-    | "postgresql://" `ByteString.isPrefixOf` raw -> parseUri dfltUser (ByteString.drop 13 raw)-    | "postgres://" `ByteString.isPrefixOf` raw -> parseUri dfltUser (ByteString.drop 11 raw)-    | otherwise -> parseKeyValue dfltUser raw+    | "postgresql://" `ByteString.isPrefixOf` raw -> parseUri dfltUser dfltHost (ByteString.drop 13 raw)+    | "postgres://" `ByteString.isPrefixOf` raw -> parseUri dfltUser dfltHost (ByteString.drop 11 raw)+    | otherwise -> parseKeyValue dfltUser dfltHost raw  -- | Resolve the default @user@ the way libpq does (@conninfo_add_defaults@ / -- @pg_fe_getauthname@ in @fe-connect.c@): the @PGUSER@ environment variable if@@ -104,10 +104,52 @@ platformUserNameLookupFailureMessage = "could not look up local user name" #endif -parseKeyValue :: ByteString -> ByteString -> ConnInfo-parseKeyValue dfltUser raw =+-- | The default @host@ used when a conninfo omits it (or gives an empty+-- value), matching libpq's own @conninfo_add_defaults@ / @PQconnectdbParams@+-- resolution: @PGHOST@ if set and non-empty, otherwise 'defaultUnixSocketDir'+-- (or @localhost@ on Windows, where there's no Unix-domain default to fall+-- back to).+--+-- An explicit @host=\/some\/path@ (or its URI equivalent) always connects via+-- Unix-domain socket regardless of this default - see+-- 'Transport.isUnixSocketHost'.+resolveDefaultHost :: IO ByteString+resolveDefaultHost = do+  pghost <- lookupEnv "PGHOST"+  pure $ case pghost of+    Just h | not (null h) -> ByteString.Char8.pack h+    _ -> compiledDefaultHost++compiledDefaultHost :: ByteString+#if defined(mingw32_HOST_OS)+compiledDefaultHost = "localhost"+#else+compiledDefaultHost = defaultUnixSocketDir+#endif++#ifndef PQI_NATIVE_DEFAULT_UNIX_SOCKET_DIR+#define PQI_NATIVE_DEFAULT_UNIX_SOCKET_DIR "/tmp"+#endif++-- | The Unix-domain socket directory used when neither @host@ nor @PGHOST@ is+-- given (non-Windows only). Defaults to @\/tmp@, the directory the upstream+-- @postgres.org@ @libpq@ uses. A distribution that compiles its own @libpq@+-- with a different default can match it here via @cabal.project@. Note this+-- needs the whole option double-quoted with the inner quotes backslash-escaped+-- (@cabal.project@'s per-package field parser tokenizes @ghc-options@+-- shell-style).+--+-- > package pqi-native+-- >   ghc-options: "-DPQI_NATIVE_DEFAULT_UNIX_SOCKET_DIR=\"/run/postgresql\""+#if !defined(mingw32_HOST_OS)+defaultUnixSocketDir :: ByteString+defaultUnixSocketDir = PQI_NATIVE_DEFAULT_UNIX_SOCKET_DIR+#endif++parseKeyValue :: ByteString -> ByteString -> ByteString -> ConnInfo+parseKeyValue dfltUser dfltHost raw =   ConnInfo-    { host = get "host" "localhost",+    { host = defaultIfEmpty dfltHost (get "host" ""),       port = maybe 5432 fst (ByteString.Char8.readInt (get "port" "5432")),       user = theUser,       database = get "dbname" theUser,@@ -124,6 +166,11 @@         | not (ByteString.null value) -> Just (key, ByteString.drop 1 value)       _ -> Nothing +defaultIfEmpty :: ByteString -> ByteString -> ByteString+defaultIfEmpty dflt raw+  | ByteString.null raw = dflt+  | otherwise = raw+ -- | Conninfo keys already surfaced via their own 'ConnInfo' fields, so they're -- excluded from 'extraParams' rather than duplicated there. reservedKeys :: Set.Set ByteString@@ -132,8 +179,8 @@ -- | Parse the authority+path portion of a @postgresql://@ URI (scheme already -- stripped). Handles @[user[:password]@][host[:port]][/dbname]@; ignores query -- parameters other than what appears in those components.-parseUri :: ByteString -> ByteString -> ConnInfo-parseUri dfltUser withoutScheme =+parseUri :: ByteString -> ByteString -> ByteString -> ConnInfo+parseUri dfltUser dfltHost withoutScheme =   ConnInfo {host, port, user, database, password, extraParams}   where     -- Split off optional "userinfo@" prefix. The '@' is unambiguous in this@@ -189,9 +236,9 @@           Just c ->             let h = ByteString.take c hostport                 p = ByteString.drop (c + 1) hostport-             in if ByteString.null h then ("localhost", readPort p) else (h, readPort p)+             in if ByteString.null h then (dfltHost, readPort p) else (h, readPort p)           Nothing ->-            (if ByteString.null hostport then "localhost" else hostport, 5432)+            (defaultIfEmpty dfltHost hostport, 5432)      readPort bs = maybe 5432 fst (ByteString.Char8.readInt bs) @@ -294,6 +341,47 @@   writeIORef (lastError connection) (Just message)   writeIORef (connStatus connection) ConnectionBad +-- | Format an initial-connect failure (the socket couldn't even be opened),+-- matching libpq's distinct phrasing for a Unix-domain socket vs. a TCP host.+connectFailureMessage :: ConnInfo -> IOException -> ByteString+connectFailureMessage connInfo err+  | Transport.isUnixSocketHost (host connInfo) =+      "could not connect to server: "+        <> ByteString.Char8.pack (show err)+        <> " (Unix domain socket '"+        <> ByteString.Char8.pack (Transport.unixSocketPath (host connInfo) (port connInfo))+        <> "')"+  | otherwise = "could not connect to server: " <> ByteString.Char8.pack (show err)++-- | The handshake-failure message ('failWith', inside 'handshake') for a+-- Unix-domain socket connection: names the socket path rather than a+-- host\/port pair, matching libpq's phrasing.+unixSocketFailureMessage :: ConnInfo -> ByteString -> ByteString+unixSocketFailureMessage connInfo fmtFields =+  "connection to server on socket '"+    <> ByteString.Char8.pack (Transport.unixSocketPath (host connInfo) (port connInfo))+    <> "' failed: "+    <> fmtFields++-- | The handshake-failure message for a TCP connection: includes the+-- resolved peer IP when available (it may not be, e.g. if the socket has+-- already been torn down), matching libpq's phrasing.+tcpFailureMessage :: Connection -> ConnInfo -> ByteString -> IO ByteString+tcpFailureMessage connection connInfo fmtFields = do+  transport <- readIORef (transport connection)+  mIp <- catch (Just <$> Transport.peerIp transport) (\(_ :: SomeException) -> pure Nothing)+  pure $ case mIp of+    Nothing -> fmtFields+    Just ip ->+      "connection to server at \""+        <> host connInfo+        <> "\" ("+        <> ip+        <> "), port "+        <> ByteString.Char8.pack (show (port connInfo))+        <> " failed: "+        <> fmtFields+ -- | Open a connection: resolve and connect the socket, send the startup -- message, and run the authentication\/startup handshake. Like libpq, a failed -- connection (whether due to a network error or a rejected handshake) yields a@@ -305,21 +393,22 @@ -- 'ConnectionBad' connection rather than throwing. establish :: ByteString -> IO Connection establish conninfo = do+  dfltHost <- resolveDefaultHost   userResult <- resolveDefaultUser   case userResult of     Left message -> do       transport <- Transport.unconnected-      connection <- newConnection False transport (parseConnInfo conninfo "")+      connection <- newConnection False transport (parseConnInfo conninfo "" dfltHost)       setError connection (ByteString.Char8.pack message)       pure connection     Right dfltUser -> do-      let info = parseConnInfo conninfo dfltUser+      let info = parseConnInfo conninfo dfltUser dfltHost       transportResult <- try @IOException (Transport.connect (host info) (port info))       case transportResult of         Left err -> do           transport <- Transport.unconnected           connection <- newConnection False transport info-          setError connection ("could not connect to server: " <> ByteString.Char8.pack (show err))+          setError connection (connectFailureMessage info err)           pure connection         Right transport -> do           connection <- newConnection False transport info@@ -332,7 +421,7 @@ nullConnection :: IO Connection nullConnection = do   transport <- Transport.unconnected-  let info = parseConnInfo "" ""+  let info = parseConnInfo "" "" "localhost"   conn <- newConnection True transport info   writeIORef (lastError conn) (Just "connection pointer is NULL\n")   pure conn@@ -418,19 +507,12 @@         _ -> startingUp     failWith fields = do       let fmtFields = formatErrorFields (Map.fromList fields)-      transport <- readIORef (transport connection)-      mIp <- catch (Just <$> Transport.peerIp transport) (\(_ :: SomeException) -> pure Nothing)-      setError connection $ case mIp of-        Nothing -> fmtFields-        Just ip ->-          "connection to server at \""-            <> host (info connection)-            <> "\" ("-            <> ip-            <> "), port "-            <> ByteString.Char8.pack (show (port (info connection)))-            <> " failed: "-            <> fmtFields+          connInfo = info connection+      message <-+        if Transport.isUnixSocketHost (host connInfo)+          then pure (unixSocketFailureMessage connInfo fmtFields)+          else tcpFailureMessage connection connInfo fmtFields+      setError connection message  -- | The SASL message round-trip used by 'Auth.scram': send a client message and -- receive the next server SASL\/auth message, projected to the bytes the SCRAM
src/transport/Pqi/Native/Transport.hs view
@@ -4,6 +4,8 @@ module Pqi.Native.Transport   ( Transport,     connect,+    isUnixSocketHost,+    unixSocketPath,     unconnected,     close,     send,@@ -30,9 +32,31 @@     readBuffer :: IORef ByteString   } --- | Open a TCP connection to the given host and port.+-- | Open a connection to the given host and port. Mirroring libpq: if the+-- host looks like an absolute path (starts with @\/@ - see 'isUnixSocketHost'),+-- it names a Unix-domain socket /directory/ rather than a TCP host, and the+-- connection is made to the socket file @'unixSocketPath' host port@ within+-- it. Otherwise a TCP connection is made, resolving the host via DNS. connect :: ByteString -> Int -> IO Transport-connect host port = do+connect host port+  | isUnixSocketHost host = connectUnix (unixSocketPath host port)+  | otherwise = connectTcp host port++-- | Whether a conninfo @host@ value names a Unix-domain socket directory+-- rather than a TCP host - i.e. it looks like an absolute path, per libpq's+-- rule: "If a host name looks like an absolute path name, it specifies+-- Unix-domain communication rather than TCP/IP communication".+isUnixSocketHost :: ByteString -> Bool+isUnixSocketHost host = not (ByteString.null host) && ByteString.head host == 0x2f -- '/'++-- | The path of the socket file libpq expects within a Unix-domain socket+-- directory: @\<directory\>\/.s.PGSQL.\<port\>@.+unixSocketPath :: ByteString -> Int -> FilePath+unixSocketPath directory port = ByteString.Char8.unpack directory <> "/.s.PGSQL." <> show port++-- | Open a TCP connection to the given host and port.+connectTcp :: ByteString -> Int -> IO Transport+connectTcp host port = do   let hints = Socket.defaultHints {Socket.addrSocketType = Socket.Stream}   addresses <-     Socket.getAddrInfo (Just hints) (Just (ByteString.Char8.unpack host)) (Just (show port))@@ -41,6 +65,18 @@     address : _ -> do       sock <- Socket.socket (Socket.addrFamily address) (Socket.addrSocketType address) (Socket.addrProtocol address)       Socket.connect sock (Socket.addrAddress address)+      buffer <- newIORef ByteString.empty+      pure Transport {socket = sock, readBuffer = buffer}++-- | Open a connection to a Unix-domain socket at the given path (the+-- directory\/@\.s\.PGSQL\.\<port\>@ file, per 'unixSocketPath').+connectUnix :: FilePath -> IO Transport+connectUnix path+  | not Socket.isUnixDomainSocketAvailable =+      ioError (userError "pqi-native: Unix-domain sockets are not supported on this platform")+  | otherwise = do+      sock <- Socket.socket Socket.AF_UNIX Socket.Stream 0+      Socket.connect sock (Socket.SockAddrUnix path)       buffer <- newIORef ByteString.empty       pure Transport {socket = sock, readBuffer = buffer}