Skip to content

SqlGuid: TryParse/format() and Create()/ODBC binding disagree on byte order, corrupting round-tripped GUIDs #596

Description

@Yaraslaut

Summary

SqlGuid::Create() (the Win32 native-GUID path) and SqlGuid::TryParse() / the std::formatter<SqlGuid> specialization write and read data[16] in two different, incompatible byte orders. Any GUID that is formatted to text via to_string()/std::format and later re-parsed via TryParse() (or vice versa) round-trips to a different physical GUID once it crosses the ODBC SQL_C_GUID boundary, even though the two textual representations look unrelated at a glance.

Root cause

  • Create()'s Win32 path (src/Lightweight/DataBinder/SqlGuid.cpp) builds data[] by splitting a GUID's Data1/Data2/Data3 fields big-endian:

    guid.data[0] = winGuid.Data1 >> 24;
    guid.data[1] = winGuid.Data1 >> 16;
    guid.data[2] = winGuid.Data1 >> 8;
    guid.data[3] = winGuid.Data1;

    This matches the native GUID/SQL_C_GUID in-memory layout, so Create()'s output binds correctly via InputParameter's SQLBindParameter(..., SQL_C_GUID, ...).

  • TryParse() (SqlGuid.hpp/SqlGuid.cpp) instead fills data[] straight from consecutive hex-pair positions in the text, i.e. data[0] is the first byte as written in the string (xxxxxxxx-...) — the opposite convention from Create().

  • std::formatter<SqlGuid>::format() (SqlGuid.hpp) reconstructs Data1/Data2/Data3 from data[] by reversing the byte order:

    (uint32_t) guid.data[3] | (uint32_t) guid.data[2] << 8 |
        (uint32_t) guid.data[1] << 16 | (uint32_t) guid.data[0] << 24

    This is the correct inverse of Create()'s layout, but the wrong inverse of TryParse()'s layout.

So TryParse and format/to_string form a self-consistent round-trip pair (parse text -> format back to the same text), and Create and the ODBC binding form a separate self-consistent pair (generate -> bind -> matches what SQL Server itself considers that GUID to be) - but the two pairs disagree with each other. A GUID created via Create(), inserted, then later formatted via to_string() for display/storage-as-text (e.g. embedding a uniqueidentifier primary key in an unrelated text/XML field) produces a string that does not match what SELECT ... WHERE col = '<that string>' finds in the database - because format() un-reverses bytes that Create() never reversed in the first place.

Reproduction

Observed downstream in an application: a SqlGuid::Create()'d value was inserted as a uniqueidentifier primary key, then Light::to_string()'d for storage elsewhere. Querying the target table with the exact string to_string() produced returned 0 rows; the actual stored row's NR, as SQL Server itself renders it (sqlcmd, SELECT NR FROM t), was:

to_string() output:  4B0BA16D-1A21-4BDA-881D-5550E28B7858
SQL Server's own value: 6DA10B4B-211A-DA4B-881D-5550E28B7858

Confirmed via direct query: SELECT NR FROM t WHERE NR = '6DA10B4B-211A-DA4B-881D-5550E28B7858' matches the row; ... WHERE NR = '4B0BA16D-1A21-4BDA-881D-5550E28B7858' returns nothing. Note the first three hex groups are exactly byte-reversed between the two strings (4B0BA16D <-> 6DA10B4B byte-swapped, etc.); the last two groups (881D-5550E28B7858) are identical, matching format()'s big-endian-only treatment of data[10..15].

Why this matters beyond cosmetics

  • Round-trips correctly only through one path at a time. An app that always uses TryParse+format/to_string together (never touching Create()/ODBC binding for the same value) or always uses Create()+ODBC binding together (never formatting to text) won't notice. Mixing them - e.g. Create() a GUID, insert it, then to_string() it for embedding in a different record as a foreign reference, then TryParse() that string back later to query - silently produces a lookup that either fails or (worse, if both write and read paths apply the same internal inconsistency) succeeds while the portable, cross-tool-correct string is wrong.
  • Breaks cross-backend/cross-tool correctness. SQL Server's uniqueidentifier mixed-endian convention (Data1/Data2/Data3 little-endian in storage, last two groups big-endian) is the reason this asymmetry exists at all; it doesn't apply to Postgres uuid or SQLite (stored as plain text via to_string() per this file's own SQLITE branches). Any consumer outside this library that reads the raw column with a standards-correct driver, or the same value round-tripped through a different backend, will see a different/wrong GUID.

Suggested fix

Make TryParse() fill data[] in the same physical byte order Create() uses (i.e. apply the same big-endian folding for the first three groups that Create()'s Win32 path already does), so Create(), TryParse(), format()/to_string(), and the ODBC SQL_C_GUID binding all agree on one internal representation. Add a round-trip test: TryParse(x) -> ODBC insert -> read back via a raw/independent query (or at minimum assert to_string(TryParse(x).value()) == x and that the resulting bytes match what Create() would produce for an equivalent value) to catch this class of regression.

Environment

  • Pinned vcpkg tag in the downstream project: v0.20260625.0
  • Confirmed still present on Lightweight's current master (8b76d5ca) as of this report.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions