Skip to content

Commit 14cfd28

Browse files
authored
SqlConnection: allow configurable encryption via SQL_COPT_SS_ENCRYPT (#578)
Closes #14 ## What Adds `SqlEncryptionMode` (`DriverDefault` / `Disabled` / `Enabled`) and a matching `SqlConnectionDataSource::encryption` field, so an application can explicitly request — or refuse — a TLS-encrypted connection. ```cpp SqlConnection::SetDefaultDataSource(SqlConnectionDataSource { .datasource = "MyServerDSN", .username = "user", .password = "password", .encryption = SqlEncryptionMode::Enabled, }); ``` ## Why this shape Previously the only way to configure encryption was to spell out a driver keyword in a raw connection string. On the DSN-based connect path (`SQLConnect`) there is no connection string to put that keyword in, so encryption was not configurable there at all — which is exactly the gap `SQL_COPT_SS_ENCRYPT` exists to close. Three judgment calls worth reviewing, since the issue specifies the mechanism but not the API: 1. **Tri-state with a `DriverDefault` sentinel**, following the existing `SqlIsolationMode::DriverDefault` precedent (`SqlTransaction.cpp:19`). The default leaves the attribute untouched, so anything that does not opt in behaves bit-for-bit as before. 2. **Fail-closed.** `SQL_COPT_SS_ENCRYPT` is a *pre-connect* attribute, so the server type is not known yet and cannot be branched on — which is why only an explicit opt-in touches it at all. If the caller did opt in and the driver rejects the attribute, the connection **fails** instead of being established. Silently downgrading a requested encrypted connection to plaintext seemed like the wrong failure mode for a security setting; happy to flip this if you disagree. 3. **The setting survives flattening into a connection string.** `ToConnectionString()` emits `Encrypt=yes|no` (nothing at all for `DriverDefault`), `FromConnectionString()` parses it back, and `SetDefaultDataSource()` now delegates to `ToConnectionString()` rather than re-formatting a subset of the fields — otherwise that path would silently drop the knob. (`std::formatter<SqlConnectInfo>` was duplicating the same format string and is now delegated too.) `SQL_COPT_SS_ENCRYPT` and its `SQL_EN_*` values live in Microsoft's `msodbcsql.h`, which unixODBC does not ship, so the values are mirrored locally rather than adding a dependency on that header. ## Tests - 11 new unit cases in `SqlConnectInfoEdgeTests.cpp`: keyword parsing (all documented spellings, case-insensitive, whitespace-tolerant, unknown → `DriverDefault`), rendering, round-tripping, comparison, and a regression guard that a non-opted-in data source renders byte-for-byte as before. - 1 new DB case in `SqlConnectionDbTests.cpp`: opens an explicitly encrypted connection against SQL Server and round-trips a query over it. `UNSUPPORTED_DATABASE`-gated for the other backends, which configure TLS through their own keywords. **Coverage gap, stated explicitly:** the `SQLSetConnectAttr(SQL_COPT_SS_ENCRYPT)` call itself is on the DSN connect path, which needs a registered DSN and so is not reachable from the CI harness (all test envs use connection strings). The end-to-end test therefore exercises the equivalent connection-string path. The attribute application itself is covered only by construction and review. ## Databases tested | Database | Result | |---|---| | `sqlite3` | 1414 cases, 1413 passed, 1 pre-existing skip | | `mssql2022` (Docker, `mcr.microsoft.com/mssql/server:2022-latest`) | 1414 cases, 1411 passed, 3 pre-existing skips | | `postgres` (Docker 16.4) | 1414 cases, 1412 passed, 2 pre-existing skips | The new encrypted-connection case was confirmed to actually *run* (not skip) and pass on `mssql2022`. ## Compilers tested - `clang-debug` (ASan + UBSan + pedantic `-Werror`) — all three databases above. **This is the one that ran the suite.** - **GCC was not exercised**, contrary to `AGENT.md` step 3. The `gcc-release` preset is Linux-gated (`Cannot use disabled configure preset`) and this is macOS. I configured GCC 15 manually with `LIGHTWEIGHT_BUILD_MODULES=ON` instead; `SqlConnection.cpp` and `SqlConnectInfo.cpp` both compiled clean under it, but the build cannot complete on macOS for reasons unrelated to this change (see below). **The GCC and modules legs need CI to be the judge.** ## Pre-existing issues found while validating (not fixed here) Building `gcc-release -D LIGHTWEIGHT_BUILD_MODULES=ON` with GCC 15 on macOS fails on untouched code. Flagging in case they bite on a compiler bump — CI currently pins GCC 14: - `SqlLogger.cpp:291`: `'std::stacktrace' has not been declared` (Homebrew libstdc++ lacks it; the `LIGHTWEIGHT_HAVE_STDCXXEXP` probe fails and the `#if` guard then leaves the call unguarded). - GCC 15 tightened the module TU-local-exposure diagnostic and now rejects three pre-existing entities: `SqlConnectInfo.hpp`'s `PrefetchDepthDefault` (namespace-scope `constexpr`, needs `inline constexpr`), `detail::kDefaultRowArrayFetchDepth`, and `Reflection::MaxReflectionMemerCount` (in the vendored `reflection-cpp` dep). None involve this PR's new symbols. ## Performance impact None. One extra `SQLSetConnectAttr` per connection, and only when the caller opts in; the default path adds a single predictable enum comparison. No allocation added on any hot path. ## Risk assessment **Low.** The entire feature is inert unless a caller sets `encryption` to something other than `DriverDefault`. The one behaviour change that reaches non-opted-in code is `SetDefaultDataSource()` delegating to `ToConnectionString()` — verified to produce a byte-identical string in that case, with a regression test pinning it. ABI: `SqlConnectionDataSource` grows a member, so this is a breaking ABI change for that struct (source compatible; the defaulted `operator<=>` now also compares the new field).
2 parents 6e5602a + a3509b3 commit 14cfd28

7 files changed

Lines changed: 471 additions & 12 deletions

File tree

docs/usage.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,47 @@ if (!sqlConnection.IsAlive())
1717
}
1818
```
1919
20+
## Connection encryption
21+
22+
By default Lightweight does not touch the driver's TLS configuration — whatever the ODBC driver, the
23+
DSN, or the connection string already says stays in force. To take explicit control, set the
24+
`encryption` field of `SqlConnectionDataSource`:
25+
26+
```cpp
27+
SqlConnection::SetDefaultDataSource(SqlConnectionDataSource {
28+
.datasource = "MyServerDSN",
29+
.username = "user",
30+
.password = "password",
31+
.encryption = SqlEncryptionMode::Enabled,
32+
});
33+
```
34+
35+
`SqlEncryptionMode` has three values:
36+
37+
| Value | Meaning |
38+
|-------|---------|
39+
| `DriverDefault` | Do not touch the setting (the default). |
40+
| `Disabled` | Request an unencrypted connection. |
41+
| `Enabled` | Request an encrypted connection. |
42+
43+
This maps onto the Microsoft SQL Server ODBC attribute
44+
[`SQL_COPT_SS_ENCRYPT`](https://learn.microsoft.com/en-us/sql/relational-databases/native-client-odbc-api/sqlsetconnectattr),
45+
which has to be applied to the connection handle *before* connecting. Because the server type is not
46+
yet known at that point, the setting is applied verbatim whenever you opt in — and if the driver
47+
rejects it, the connection **fails** rather than silently falling back to an unencrypted channel.
48+
Leave the field at `DriverDefault` on backends that configure TLS through their own keywords
49+
(PostgreSQL's `sslmode`, for example).
50+
51+
When connecting with a raw `SqlConnectionString` instead, use the driver's own `Encrypt=` keyword —
52+
it is what `SqlConnectionDataSource::ToConnectionString()` emits, and
53+
`SqlConnectionDataSource::FromConnectionString()` reads it back:
54+
55+
```cpp
56+
auto const connectionString = SqlConnectionString {
57+
.value = "Driver={ODBC Driver 18 for SQL Server};SERVER=db;UID=user;PWD=password;Encrypt=yes"
58+
};
59+
```
60+
2061
## Raw SQL Queries
2162

2263
To directly make a call to the database use `ExecuteDirect` function, for example

src/Lightweight/Lightweight.cppm

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ using Lightweight::Field;
5555
using Lightweight::FieldNameAt;
5656
using Lightweight::FieldNameOf;
5757
using Lightweight::FieldWithStorage;
58+
using Lightweight::FormatEncryptionMode;
5859
using Lightweight::FormatName;
5960
using Lightweight::FormatType;
6061
using Lightweight::FullyQualifiedNameOf;
@@ -99,6 +100,7 @@ using Lightweight::MemberClassType;
99100
using Lightweight::MemberIndexOf;
100101
using Lightweight::NotSqlElements;
101102
using Lightweight::ParseConnectionString;
103+
using Lightweight::ParseEncryptionMode;
102104
using Lightweight::PostgreSqlFormatter;
103105
using Lightweight::PrimaryKey;
104106
using Lightweight::QualifiedColumnName;
@@ -156,6 +158,7 @@ using Lightweight::SqlDynamicUtf16String;
156158
using Lightweight::SqlDynamicUtf32String;
157159
using Lightweight::SqlDynamicWideString;
158160
using Lightweight::SqlElements;
161+
using Lightweight::SqlEncryptionMode;
159162
using Lightweight::SqlError;
160163
using Lightweight::SqlErrorCategory;
161164
using Lightweight::SqlErrorInfo;

src/Lightweight/SqlConnectInfo.cpp

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
#include "SqlConnectInfo.hpp"
44

55
#include <algorithm>
6+
#include <array>
67
#include <filesystem>
78
#include <fstream>
89
#include <ranges>
910
#include <regex>
1011
#include <string>
1112
#include <string_view>
1213
#include <system_error>
14+
#include <utility>
1315

1416
namespace Lightweight
1517
{
@@ -48,7 +50,50 @@ namespace
4850
return result;
4951
}
5052

53+
/// Maps the ODBC `Encrypt=` keyword spellings onto SqlEncryptionMode. The first entry of each mode
54+
/// is also its canonical rendering, so the table drives both directions.
55+
constexpr std::array<std::pair<std::string_view, SqlEncryptionMode>, 8> EncryptionModeSpellings { {
56+
{ "yes", SqlEncryptionMode::Enabled },
57+
{ "true", SqlEncryptionMode::Enabled },
58+
{ "1", SqlEncryptionMode::Enabled },
59+
// `mandatory` is the ODBC Driver 18 synonym of `yes`.
60+
{ "mandatory", SqlEncryptionMode::Enabled },
61+
{ "no", SqlEncryptionMode::Disabled },
62+
{ "false", SqlEncryptionMode::Disabled },
63+
{ "0", SqlEncryptionMode::Disabled },
64+
// `optional` is the ODBC Driver 18 synonym of `no`.
65+
{ "optional", SqlEncryptionMode::Disabled },
66+
} };
67+
68+
constexpr bool EqualsIgnoreCase(std::string_view a, std::string_view b) noexcept
69+
{
70+
return std::ranges::equal(a, b, [](char x, char y) {
71+
return std::tolower(static_cast<unsigned char>(x)) == std::tolower(static_cast<unsigned char>(y));
72+
});
73+
}
74+
5175
} // end namespace
76+
77+
SqlEncryptionMode ParseEncryptionMode(std::string_view value) noexcept
78+
{
79+
auto const trimmed = Trim(value);
80+
for (auto const& [spelling, mode]: EncryptionModeSpellings)
81+
if (EqualsIgnoreCase(spelling, trimmed))
82+
return mode;
83+
return SqlEncryptionMode::DriverDefault;
84+
}
85+
86+
std::string_view FormatEncryptionMode(SqlEncryptionMode mode) noexcept
87+
{
88+
if (mode == SqlEncryptionMode::DriverDefault)
89+
return {};
90+
91+
for (auto const& [spelling, candidate]: EncryptionModeSpellings)
92+
if (candidate == mode)
93+
return spelling;
94+
return {};
95+
}
96+
5297
std::string SqlConnectionString::Sanitized() const
5398
{
5499
return SanitizePwd(value);
@@ -177,6 +222,9 @@ SqlConnectionDataSource SqlConnectionDataSource::FromConnectionString(SqlConnect
177222
if (auto timeout = parsedConnectionStringPairs.extract("TIMEOUT"); !timeout.empty())
178223
result.timeout = std::chrono::seconds(std::stoi(timeout.mapped()));
179224

225+
if (auto encrypt = parsedConnectionStringPairs.extract("ENCRYPT"); !encrypt.empty())
226+
result.encryption = ParseEncryptionMode(encrypt.mapped());
227+
180228
return result;
181229
}
182230

src/Lightweight/SqlConnectInfo.hpp

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66

77
#include <chrono>
88
#include <cstddef>
9+
#include <cstdint>
910
#include <format>
1011
#include <map>
1112
#include <string>
13+
#include <string_view>
1214
#include <variant>
1315

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

28+
/// @ingroup CoreApi
29+
/// @brief Whether the client/server connection is TLS-encrypted.
30+
///
31+
/// Maps onto the Microsoft SQL Server ODBC connection attribute @c SQL_COPT_SS_ENCRYPT, which must be
32+
/// set on the connection handle *before* connecting. This is the only way to request encryption on the
33+
/// DSN-based connect path (@c SQLConnect), where there is no connection string for an @c Encrypt=
34+
/// keyword to live in.
35+
///
36+
/// @see https://learn.microsoft.com/en-us/sql/relational-databases/native-client-odbc-api/sqlsetconnectattr
37+
enum class SqlEncryptionMode : std::uint8_t
38+
{
39+
/// Leave the attribute untouched — whatever the driver, DSN, or connection string configures wins.
40+
///
41+
/// This is the default, so an application that does not opt in behaves exactly as before.
42+
DriverDefault = 0,
43+
44+
/// Request an unencrypted connection (@c SQL_EN_OFF).
45+
Disabled = 1,
46+
47+
/// Request an encrypted connection (@c SQL_EN_ON).
48+
Enabled = 2,
49+
};
50+
51+
/// Parses an ODBC @c Encrypt= connection-string value into a @ref SqlEncryptionMode.
52+
///
53+
/// Recognizes the spellings the SQL Server drivers accept, case-insensitively: @c yes / @c no,
54+
/// @c true / @c false, @c 1 / @c 0, and the ODBC Driver 18 synonyms @c mandatory / @c optional.
55+
///
56+
/// @warning @c SqlEncryptionMode has no representation for ODBC Driver 18's @c strict (TDS 8.0 with
57+
/// mandatory certificate validation), so @c Encrypt=strict parses as
58+
/// @c SqlEncryptionMode::DriverDefault and is *dropped* by a subsequent
59+
/// @ref SqlConnectionDataSource::ToConnectionString(). Keep such connection strings as a raw
60+
/// @ref SqlConnectionString instead of round-tripping them through a data source.
61+
///
62+
/// @param value The raw keyword value.
63+
/// @return The matching mode, or @c SqlEncryptionMode::DriverDefault if @p value is not recognized.
64+
[[nodiscard]] LIGHTWEIGHT_API SqlEncryptionMode ParseEncryptionMode(std::string_view value) noexcept;
65+
66+
/// Renders a @ref SqlEncryptionMode as the ODBC @c Encrypt= connection-string value.
67+
///
68+
/// @param mode The mode to render.
69+
/// @return @c "yes" or @c "no", or an empty view for @c SqlEncryptionMode::DriverDefault (which is
70+
/// expressed by omitting the keyword entirely).
71+
[[nodiscard]] LIGHTWEIGHT_API std::string_view FormatEncryptionMode(SqlEncryptionMode mode) noexcept;
72+
2673
/// @ingroup CoreApi
2774
/// Represents an ODBC connection string.
2875
struct SqlConnectionString
@@ -82,15 +129,27 @@ struct [[nodiscard]] SqlConnectionDataSource
82129
/// native row-array fetching (see @c SqlConnection::SupportsNativeRowArrayFetch).
83130
std::size_t defaultPrefetchDepth = PrefetchDepthDefault;
84131

132+
/// @brief Whether to request a TLS-encrypted connection.
133+
///
134+
/// Defaults to @c SqlEncryptionMode::DriverDefault, which leaves the driver's own configuration in
135+
/// charge. Any other value is applied to the connection handle before connecting, and a driver that
136+
/// rejects it fails the connection rather than silently downgrading to plaintext.
137+
SqlEncryptionMode encryption = SqlEncryptionMode::DriverDefault;
138+
85139
/// Constructs a SqlConnectionDataSource from the given connection string.
86140
LIGHTWEIGHT_API static SqlConnectionDataSource FromConnectionString(SqlConnectionString const& value);
87141

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

96155
/// Three-way comparison operator.
@@ -108,10 +167,7 @@ struct std::formatter<Lightweight::SqlConnectInfo>: std::formatter<std::string>
108167
{
109168
if (auto const* dsn = std::get_if<Lightweight::SqlConnectionDataSource>(&info))
110169
{
111-
return formatter<string>::format(
112-
std::format(
113-
"DSN={};UID={};PWD={};TIMEOUT={}", dsn->datasource, dsn->username, dsn->password, dsn->timeout.count()),
114-
ctx);
170+
return formatter<string>::format(dsn->ToConnectionString().value, ctx);
115171
}
116172
else if (auto const* connectionString = std::get_if<Lightweight::SqlConnectionString>(&info))
117173
{

src/Lightweight/SqlConnection.cpp

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include <algorithm>
1313
#include <array>
1414
#include <mutex>
15+
#include <optional>
1516
#include <stdexcept>
1617

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

49+
// SQL_COPT_SS_ENCRYPT and its SQL_EN_* values are declared in the Microsoft-specific `msodbcsql.h`
50+
// (formerly `sqlncli.h`), which unixODBC does not ship and which we must not take a dependency on —
51+
// Lightweight builds against plain unixODBC on Linux/macOS. Mirror the values instead; they are part
52+
// of the driver's stable ABI.
53+
// https://learn.microsoft.com/en-us/sql/relational-databases/native-client-odbc-api/sqlsetconnectattr
54+
constexpr SQLINTEGER SqlCoptSsEncrypt = 1200 + 23; // SQL_COPT_SS_BASE + 23
55+
constexpr SQLULEN SqlEncryptOff = 0; // SQL_EN_OFF
56+
constexpr SQLULEN SqlEncryptOn = 1; // SQL_EN_ON
57+
58+
/// Maps a SqlEncryptionMode onto the SQL_COPT_SS_ENCRYPT attribute value to set.
59+
///
60+
/// @param mode The requested encryption mode.
61+
/// @return The attribute value, or `std::nullopt` for `DriverDefault` (the attribute is then not
62+
/// touched at all, leaving the driver's own configuration in charge).
63+
constexpr std::optional<SQLULEN> ToOdbcEncryptValue(SqlEncryptionMode mode) noexcept
64+
{
65+
switch (mode)
66+
{
67+
case SqlEncryptionMode::DriverDefault:
68+
return std::nullopt;
69+
case SqlEncryptionMode::Disabled:
70+
return SqlEncryptOff;
71+
case SqlEncryptionMode::Enabled:
72+
return SqlEncryptOn;
73+
}
74+
// Unreachable: the switch above is exhaustive over the enumerators, and every arm returns.
75+
// It stays because a switch over a scoped enum without a default label still leaves the
76+
// function without a return statement as far as -Wreturn-type is concerned. The coverage
77+
// report flags this line for that reason, not because a test is missing.
78+
return std::nullopt;
79+
}
80+
4881
} // namespace
4982

5083
// =====================================================================================================================
@@ -153,11 +186,9 @@ void SqlConnection::SetDefaultConnectionString(SqlConnectionString const& connec
153186

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

163194
SqlConnectionString const& SqlConnection::ConnectionString() const noexcept
@@ -270,6 +301,32 @@ bool SqlConnection::Connect(SqlConnectionDataSource const& info) noexcept
270301
return false;
271302
}
272303

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

0 commit comments

Comments
 (0)