Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,47 @@ if (!sqlConnection.IsAlive())
}
```

## Connection encryption

By default Lightweight does not touch the driver's TLS configuration — whatever the ODBC driver, the
DSN, or the connection string already says stays in force. To take explicit control, set the
`encryption` field of `SqlConnectionDataSource`:

```cpp
SqlConnection::SetDefaultDataSource(SqlConnectionDataSource {
.datasource = "MyServerDSN",
.username = "user",
.password = "password",
.encryption = SqlEncryptionMode::Enabled,
});
```

`SqlEncryptionMode` has three values:

| Value | Meaning |
|-------|---------|
| `DriverDefault` | Do not touch the setting (the default). |
| `Disabled` | Request an unencrypted connection. |
| `Enabled` | Request an encrypted connection. |

This maps onto the Microsoft SQL Server ODBC attribute
[`SQL_COPT_SS_ENCRYPT`](https://learn.microsoft.com/en-us/sql/relational-databases/native-client-odbc-api/sqlsetconnectattr),
which has to be applied to the connection handle *before* connecting. Because the server type is not
yet known at that point, the setting is applied verbatim whenever you opt in — and if the driver
rejects it, the connection **fails** rather than silently falling back to an unencrypted channel.
Leave the field at `DriverDefault` on backends that configure TLS through their own keywords
(PostgreSQL's `sslmode`, for example).

When connecting with a raw `SqlConnectionString` instead, use the driver's own `Encrypt=` keyword —
it is what `SqlConnectionDataSource::ToConnectionString()` emits, and
`SqlConnectionDataSource::FromConnectionString()` reads it back:

```cpp
auto const connectionString = SqlConnectionString {
.value = "Driver={ODBC Driver 18 for SQL Server};SERVER=db;UID=user;PWD=password;Encrypt=yes"
};
```

## Raw SQL Queries

To directly make a call to the database use `ExecuteDirect` function, for example
Expand Down
3 changes: 3 additions & 0 deletions src/Lightweight/Lightweight.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ using Lightweight::Field;
using Lightweight::FieldNameAt;
using Lightweight::FieldNameOf;
using Lightweight::FieldWithStorage;
using Lightweight::FormatEncryptionMode;
using Lightweight::FormatName;
using Lightweight::FormatType;
using Lightweight::FullyQualifiedNameOf;
Expand Down Expand Up @@ -98,6 +99,7 @@ using Lightweight::MemberClassType;
using Lightweight::MemberIndexOf;
using Lightweight::NotSqlElements;
using Lightweight::ParseConnectionString;
using Lightweight::ParseEncryptionMode;
using Lightweight::PostgreSqlFormatter;
using Lightweight::PrimaryKey;
using Lightweight::QualifiedColumnName;
Expand Down Expand Up @@ -155,6 +157,7 @@ using Lightweight::SqlDynamicUtf16String;
using Lightweight::SqlDynamicUtf32String;
using Lightweight::SqlDynamicWideString;
using Lightweight::SqlElements;
using Lightweight::SqlEncryptionMode;
using Lightweight::SqlError;
using Lightweight::SqlErrorCategory;
using Lightweight::SqlErrorInfo;
Expand Down
48 changes: 48 additions & 0 deletions src/Lightweight/SqlConnectInfo.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
#include "SqlConnectInfo.hpp"

#include <algorithm>
#include <array>
#include <filesystem>
#include <fstream>
#include <ranges>
#include <regex>
#include <string>
#include <string_view>
#include <system_error>
#include <utility>

namespace Lightweight
{
Expand Down Expand Up @@ -48,7 +50,50 @@ namespace
return result;
}

/// Maps the ODBC `Encrypt=` keyword spellings onto SqlEncryptionMode. The first entry of each mode
/// is also its canonical rendering, so the table drives both directions.
constexpr std::array<std::pair<std::string_view, SqlEncryptionMode>, 8> EncryptionModeSpellings { {
{ "yes", SqlEncryptionMode::Enabled },
{ "true", SqlEncryptionMode::Enabled },
{ "1", SqlEncryptionMode::Enabled },
// `mandatory` is the ODBC Driver 18 synonym of `yes`.
{ "mandatory", SqlEncryptionMode::Enabled },
{ "no", SqlEncryptionMode::Disabled },
{ "false", SqlEncryptionMode::Disabled },
{ "0", SqlEncryptionMode::Disabled },
// `optional` is the ODBC Driver 18 synonym of `no`.
{ "optional", SqlEncryptionMode::Disabled },
} };

constexpr bool EqualsIgnoreCase(std::string_view a, std::string_view b) noexcept
{
return std::ranges::equal(a, b, [](char x, char y) {
return std::tolower(static_cast<unsigned char>(x)) == std::tolower(static_cast<unsigned char>(y));
});
}

} // end namespace

SqlEncryptionMode ParseEncryptionMode(std::string_view value) noexcept
{
auto const trimmed = Trim(value);
for (auto const& [spelling, mode]: EncryptionModeSpellings)
if (EqualsIgnoreCase(spelling, trimmed))
return mode;
return SqlEncryptionMode::DriverDefault;
}

std::string_view FormatEncryptionMode(SqlEncryptionMode mode) noexcept
{
if (mode == SqlEncryptionMode::DriverDefault)
return {};

for (auto const& [spelling, candidate]: EncryptionModeSpellings)
if (candidate == mode)
return spelling;
return {};
}

std::string SqlConnectionString::Sanitized() const
{
return SanitizePwd(value);
Expand Down Expand Up @@ -177,6 +222,9 @@ SqlConnectionDataSource SqlConnectionDataSource::FromConnectionString(SqlConnect
if (auto timeout = parsedConnectionStringPairs.extract("TIMEOUT"); !timeout.empty())
result.timeout = std::chrono::seconds(std::stoi(timeout.mapped()));

if (auto encrypt = parsedConnectionStringPairs.extract("ENCRYPT"); !encrypt.empty())
result.encryption = ParseEncryptionMode(encrypt.mapped());

return result;
}

Expand Down
70 changes: 63 additions & 7 deletions src/Lightweight/SqlConnectInfo.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@

#include <chrono>
#include <cstddef>
#include <cstdint>
#include <format>
#include <map>
#include <string>
#include <string_view>
#include <variant>

namespace Lightweight
Expand All @@ -23,6 +25,51 @@ namespace Lightweight
/// a value <= 1 disables prefetch.
constexpr std::size_t PrefetchDepthDefault = 1000;

/// @ingroup CoreApi
/// @brief Whether the client/server connection is TLS-encrypted.
///
/// Maps onto the Microsoft SQL Server ODBC connection attribute @c SQL_COPT_SS_ENCRYPT, which must be
/// set on the connection handle *before* connecting. This is the only way to request encryption on the
/// DSN-based connect path (@c SQLConnect), where there is no connection string for an @c Encrypt=
/// keyword to live in.
///
/// @see https://learn.microsoft.com/en-us/sql/relational-databases/native-client-odbc-api/sqlsetconnectattr
enum class SqlEncryptionMode : std::uint8_t
{
/// Leave the attribute untouched — whatever the driver, DSN, or connection string configures wins.
///
/// This is the default, so an application that does not opt in behaves exactly as before.
DriverDefault = 0,

/// Request an unencrypted connection (@c SQL_EN_OFF).
Disabled = 1,

/// Request an encrypted connection (@c SQL_EN_ON).
Enabled = 2,
};

/// Parses an ODBC @c Encrypt= connection-string value into a @ref SqlEncryptionMode.
///
/// Recognizes the spellings the SQL Server drivers accept, case-insensitively: @c yes / @c no,
/// @c true / @c false, @c 1 / @c 0, and the ODBC Driver 18 synonyms @c mandatory / @c optional.
///
/// @warning @c SqlEncryptionMode has no representation for ODBC Driver 18's @c strict (TDS 8.0 with
/// mandatory certificate validation), so @c Encrypt=strict parses as
/// @c SqlEncryptionMode::DriverDefault and is *dropped* by a subsequent
/// @ref SqlConnectionDataSource::ToConnectionString(). Keep such connection strings as a raw
/// @ref SqlConnectionString instead of round-tripping them through a data source.
///
/// @param value The raw keyword value.
/// @return The matching mode, or @c SqlEncryptionMode::DriverDefault if @p value is not recognized.
[[nodiscard]] LIGHTWEIGHT_API SqlEncryptionMode ParseEncryptionMode(std::string_view value) noexcept;

/// Renders a @ref SqlEncryptionMode as the ODBC @c Encrypt= connection-string value.
///
/// @param mode The mode to render.
/// @return @c "yes" or @c "no", or an empty view for @c SqlEncryptionMode::DriverDefault (which is
/// expressed by omitting the keyword entirely).
[[nodiscard]] LIGHTWEIGHT_API std::string_view FormatEncryptionMode(SqlEncryptionMode mode) noexcept;

/// @ingroup CoreApi
/// Represents an ODBC connection string.
struct SqlConnectionString
Expand Down Expand Up @@ -82,15 +129,27 @@ struct [[nodiscard]] SqlConnectionDataSource
/// native row-array fetching (see @c SqlConnection::SupportsNativeRowArrayFetch).
std::size_t defaultPrefetchDepth = PrefetchDepthDefault;

/// @brief Whether to request a TLS-encrypted connection.
///
/// Defaults to @c SqlEncryptionMode::DriverDefault, which leaves the driver's own configuration in
/// charge. Any other value is applied to the connection handle before connecting, and a driver that
/// rejects it fails the connection rather than silently downgrading to plaintext.
SqlEncryptionMode encryption = SqlEncryptionMode::DriverDefault;

/// Constructs a SqlConnectionDataSource from the given connection string.
LIGHTWEIGHT_API static SqlConnectionDataSource FromConnectionString(SqlConnectionString const& value);

/// Converts this data source to an ODBC connection string.
///
/// The @c Encrypt= keyword is emitted only when @ref encryption is not
/// @c SqlEncryptionMode::DriverDefault, so the rendering of a data source that did not opt in is
/// byte-for-byte what it always was.
[[nodiscard]] LIGHTWEIGHT_API SqlConnectionString ToConnectionString() const
{
return SqlConnectionString {
.value = std::format("DSN={};UID={};PWD={};TIMEOUT={}", datasource, username, password, timeout.count())
};
auto value = std::format("DSN={};UID={};PWD={};TIMEOUT={}", datasource, username, password, timeout.count());
if (auto const encryptValue = FormatEncryptionMode(encryption); !encryptValue.empty())
value += std::format(";Encrypt={}", encryptValue);
return SqlConnectionString { .value = std::move(value) };
}

/// Three-way comparison operator.
Expand All @@ -108,10 +167,7 @@ struct std::formatter<Lightweight::SqlConnectInfo>: std::formatter<std::string>
{
if (auto const* dsn = std::get_if<Lightweight::SqlConnectionDataSource>(&info))
{
return formatter<string>::format(
std::format(
"DSN={};UID={};PWD={};TIMEOUT={}", dsn->datasource, dsn->username, dsn->password, dsn->timeout.count()),
ctx);
return formatter<string>::format(dsn->ToConnectionString().value, ctx);
}
else if (auto const* connectionString = std::get_if<Lightweight::SqlConnectionString>(&info))
{
Expand Down
67 changes: 62 additions & 5 deletions src/Lightweight/SqlConnection.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include <algorithm>
#include <array>
#include <mutex>
#include <optional>
#include <stdexcept>

#include <sql.h>
Expand Down Expand Up @@ -45,6 +46,38 @@ namespace
return std::string { reinterpret_cast<char const*>(utf8.data()), utf8.size() };
}

// SQL_COPT_SS_ENCRYPT and its SQL_EN_* values are declared in the Microsoft-specific `msodbcsql.h`
// (formerly `sqlncli.h`), which unixODBC does not ship and which we must not take a dependency on —
// Lightweight builds against plain unixODBC on Linux/macOS. Mirror the values instead; they are part
// of the driver's stable ABI.
// https://learn.microsoft.com/en-us/sql/relational-databases/native-client-odbc-api/sqlsetconnectattr
constexpr SQLINTEGER SqlCoptSsEncrypt = 1200 + 23; // SQL_COPT_SS_BASE + 23
constexpr SQLULEN SqlEncryptOff = 0; // SQL_EN_OFF
constexpr SQLULEN SqlEncryptOn = 1; // SQL_EN_ON

/// Maps a SqlEncryptionMode onto the SQL_COPT_SS_ENCRYPT attribute value to set.
///
/// @param mode The requested encryption mode.
/// @return The attribute value, or `std::nullopt` for `DriverDefault` (the attribute is then not
/// touched at all, leaving the driver's own configuration in charge).
constexpr std::optional<SQLULEN> ToOdbcEncryptValue(SqlEncryptionMode mode) noexcept
{
switch (mode)
{
case SqlEncryptionMode::DriverDefault:
return std::nullopt;
case SqlEncryptionMode::Disabled:
return SqlEncryptOff;
case SqlEncryptionMode::Enabled:
return SqlEncryptOn;
}
// Unreachable: the switch above is exhaustive over the enumerators, and every arm returns.
// It stays because a switch over a scoped enum without a default label still leaves the
// function without a return statement as far as -Wreturn-type is concerned. The coverage
// report flags this line for that reason, not because a test is missing.
return std::nullopt;
}

} // namespace

// =====================================================================================================================
Expand Down Expand Up @@ -153,11 +186,9 @@ void SqlConnection::SetDefaultConnectionString(SqlConnectionString const& connec

void SqlConnection::SetDefaultDataSource(SqlConnectionDataSource const& dataSource) noexcept
{
gDefaultConnectionString = SqlConnectionString { .value = std::format("DSN={};UID={};PWD={};TIMEOUT={}",
dataSource.datasource,
dataSource.username,
dataSource.password,
dataSource.timeout.count()) };
// Delegate rather than re-format: ToConnectionString() is the single place that knows which fields
// (including the optional `Encrypt=` keyword) have to survive the flattening into a connection string.
gDefaultConnectionString = dataSource.ToConnectionString();
}

SqlConnectionString const& SqlConnection::ConnectionString() const noexcept
Expand Down Expand Up @@ -270,6 +301,32 @@ bool SqlConnection::Connect(SqlConnectionDataSource const& info) noexcept
return false;
}

// SQL_COPT_SS_ENCRYPT is a pre-connect attribute, so it has to be set here rather than in
// PostConnect() — which also means the server type is not known yet and cannot be branched on.
// Only an explicit opt-in touches the attribute, so non-SQL-Server drivers are unaffected by
// default. When the caller *did* opt in and the driver rejects the attribute, the connection is
// failed rather than established: silently downgrading a requested encrypted connection to
// plaintext would be the wrong failure mode for a security setting.
//
// Caveat: the DBC handle is reused across Connect() calls (see SQLDisconnect above), and ODBC
// offers no way to restore a connection attribute to "driver default". So reconnecting the same
// SqlConnection with SqlEncryptionMode::DriverDefault after an explicit opt-in keeps the
// previously applied value. Use a fresh SqlConnection when the encryption request changes.
if (auto const encryptValue = ToOdbcEncryptValue(info.encryption))
{
// NOLINTNEXTLINE(performance-no-int-to-ptr)
sqlReturn = SQLSetConnectAttrW(m_hDbc, SqlCoptSsEncrypt, (SQLPOINTER) *encryptValue, SQL_IS_UINTEGER);
if (!SQL_SUCCEEDED(sqlReturn))
{
// Not reachable from the test suite: this needs a driver manager that rejects
// SQL_COPT_SS_ENCRYPT at set time. Both unixODBC and the Windows driver manager defer
// driver-specific connection attributes until a driver is loaded, so every driver in
// the matrix accepts the call here and surfaces a refusal from SQLConnectW instead.
SqlLogger::GetLogger().OnError(LastError());
return false;
}
}

sqlReturn = SQLConnectW(m_hDbc,
wDataSource.data(),
wDataSource.length(),
Expand Down
Loading
Loading