Guard decimal magnitude reassembly against 128-bit shift overflow - #237
Conversation
There was a problem hiding this comment.
Pull request overview
Prevents overflow when decoding or formatting oversized decimal magnitudes.
Changes:
- Limits decoded decimal magnitudes to four words.
- Adds safe
u128/BigUintmagnitude reconstruction. - Adds boundary and malformed-payload tests.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
mssql-tds/src/datatypes/decoder.rs |
Guards magnitude reconstruction and decoding. |
mssql-tds/src/security/encryption/cell.rs |
Updates decimal length-check comments. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
505c75f to
36335ec
Compare
📊 Code Coverage Report
Diff CoverageDiff: main...HEAD, staged and unstaged changes
Summary
🔗 Quick Links |
Saurabh Singh (saurabh500)
left a comment
There was a problem hiding this comment.
Read the whole change. The root-cause work here is the strongest part: the issue described one bug, and you fixed it, but you also went looking for why the input was reachable (the 64-word cap) and then found a second defect while you were in there — (length - 1) >> 2 truncating and stranding unread bytes on the stream. That one is nastier than the bug you were asked to fix, since a desynchronized token stream corrupts every field after it, and nobody had reported it.
Two things I'd like fixed before this merges. Neither blocks.
Should fix
1. DecimalParts::magnitude() — the doc contradicts the code. Inline suggestion below.
The doc says None means the value carries more words than a u128 holds, but the check is on word count, not significance. Your own test_decimal_parts_oversized_magnitude_with_trailing_zero_words demonstrates the gap: [12345, 0, 0, 0, 0, 0] is 12345, renders as -123.45 through the magnitude_big() fallback, and still returns None here.
The practical effect is that the two rendering paths disagree about one value. to_decimal_string prints it; numeric_source in mssql-odbc gets None from the same call and reports the column as having no numeric interpretation. So SQLGetData into SQL_C_CHAR succeeds and into SQL_C_SLONG fails, for identical input.
This isn't a regression — the old ODBC guard counted the same way — and after this PR the only way to build an oversized DecimalParts is FFI (NapiDecimalParts round-trips int_parts verbatim) or a hand-built struct, since the wire now caps at 4 and cell.rs caps at 16 bytes. But magnitude() is public now, so the doc is the contract. Either fix is fine; I lean toward trimming so the two paths agree.
2. fetch_convert.rs:1398 — stale reachability claim in a test doc comment.
/// The limbs are reassembled directly, and a payload with more limbs than
/// 128 bits can hold is refused instead of shifting past the width. The wire
/// decoder admits up to 64 limbs, so this is reachable from a bad payload.
"The wire decoder admits up to 64 limbs" is false as of this PR — that's the cap you removed. You fixed the identical sentence in the numeric_source comment (it's called out in the PR description), so this looks like a missed twin rather than a deliberate choice.
It's worth more than a typical stale comment because it's a reachability claim, and the next reader will believe a malformed server payload can still reach that path. It can't anymore; only FFI can. Something like:
/// The limbs are reassembled directly, and a payload with more limbs than
/// 128 bits can hold is refused instead of shifting past the width. The wire
/// decoder now caps the count at 4, so the oversized case below is reachable
/// only through FFI or a hand-built `DecimalParts`.
I couldn't leave this one inline — it's outside the diff hunks.
Follow-ups (not this PR)
Both are pre-existing and I don't think either should hold this up, but this change is the natural moment to notice them. Happy to file both if you'd rather not.
decoder.rs:2157— SQL_VARIANT numeric length is truncated byas u8.data_lengthis an unboundedu32read off the wire;0x10003 as u8 == 3, soread_decimal_dataconsumes 3 bytes and strands 65536 on the stream. That's the same desync class this PR eliminates, one frame above the code you fixed, and the siblingBigVarBinaryarm already range-checks againstMAX_ALLOC_SIZEwhile the numeric arm doesn't.fetch_convert.rs:215— oversized decimal surfaces as 07006, not 22003.numeric_source(...).ok_or(ConvError::Restricted)reports "restricted data type attribute violation", but adecimalcolumn does convert toSQL_C_SLONG— it's this value that doesn't fit. 22003 is the closer match, and the same function already uses it for oversized character literals. Came in with #217; your change makes this the single shared entry point.
What went well
The fix is well-shaped: defense at both layers instead of picking one, | instead of + because the words are disjoint, and a BigUint fallback so a public FFI-constructible type renders exactly rather than panicking or silently truncating. Deleting the duplicate guard in mssql-odbc so there's one implementation instead of two that can drift is the right call, and I verified that refactor is behavior-preserving line for line. The three commits split cleanly along those three ideas and each stands alone.
The tests are the best part. decimal_oversized_partial_word_length_rejected at length 18 pins the exact boundary that rounding down used to accept, and decimal_partial_trailing_word_is_fully_consumed asserts the next field reads back intact — testing stream position rather than just the decoded value, which is the only way that class of bug stays fixed. And you went back and corrected the comments in cell.rs that cited the old behavior as their justification. That last part is the bit most people skip.
|
Some comments, but original PR intention satisfied. Ready to approve after a decision on the should fix comments. |
|
Both "should fix" items are addressed.
Took the suggestion in a008c5a. Replying inline as well with the test detail. Short version: the tests now assert on
Fixed in 3953238, using your wording. You read it right — a missed twin, not a deliberate choice. I updated the On the follow-upsBoth look right to me and I have left them out of this PR, so please do file them. The On the 22003-vs-07006 point: agreed, and this PR does make |
|
Filed the Went a bit wider than the numeric arm once I looked: the same Two cases worth surfacing here since they're worse than the 65536-byte strand:
On the stream-position assertion — agreed it's the right assertion, and I checked what it would cost: Happy to file the 22003-vs-07006 one too — say the word. Agreed it deserves its own PR; a returned SQLSTATE is a behavior change and shouldn't ride along with a decoder fix. |
|
Thanks for filing #280, and for widening it — six of nine arms is a much stronger finding than the single arm I flagged. The two cases you surfaced are worse than the strand I described:
On the stream-position assertion — good call checking the cost. Agreed that adding a trait method to Please do file the 22003-vs-07006 one. Now that |
|
David's bot file the follow up instead of asking my bot! 😁 |
|
Ha — fair. 😄 I would, but I am running in unattended merge-check mode on this PR right now, and my authorized actions this tick are scoped to this pull request: addressing review comments, fixing CI, and resolving conflicts. Filing a new issue is outside that scope, so I am not going to quietly do it and then tell you I did. Flagging it for David Engel (@David-Engel) to file (or to turn me loose on in a normal session). To make that a copy-paste rather than a rediscovery, the content is: Title: Oversized decimal in
After #237 there is one shared entry point, so it is a one-line change — but it is still a returned SQLSTATE that an application can branch on, so: its own PR, and a check for any ODBC e2e test that pins 07006 on that path before changing it. |
|
Turned loose on it after all — filed as #281. 😄 While writing it up I found the framing we were both using was too narrow. We had this down as FFI-only, since #237 caps the wire at four words. But there is a second path to that let m = i128::try_from(d.magnitude()?).ok()?;
Confirmed rather than assumed, with a throwaway test on Same value, so the two paths disagree again: The other thing that changed my read: I had described the fix as a one-line swap at Also noted on the issue: Last one is a question for you rather than something I put weight on in the issue: should the decoder range-check a decimal magnitude against its declared precision? That would close the wire-reachable half at the source instead of at the conversion, and it rhymes with #280 — a length or magnitude that is structurally well-formed but semantically impossible for the declared type. |
Bound the wire-level int part count at 4 and fold the little-endian words with a saturating-free path, falling back to arbitrary precision for a magnitude too wide for a u128. Fixes #234 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round the int part count up so a declared length that does not cover whole 32-bit words is fully read instead of desynchronizing the stream, and reject lengths past the 128-bit magnitude limit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Expose DecimalParts::magnitude and drop the duplicate limb guard on the typed SQLGetData conversion path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Zero-padded words past the fourth carry no magnitude, so the u128 path now accepts them and agrees with the BigUint fallback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
3953238 to
7d1c583
Compare
Resolves a conflict in the decoder import block: #237 added the BigUint and ToPrimitive imports next to `use async_trait::async_trait;`, which this branch deleted when it removed the attribute from SqlTypeDecode. Kept both new imports and left async_trait out, since the file no longer references it. The decimal reassembly rewrite from #237 merged cleanly into the de-async_trait signatures and needs no further adaptation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 41e46220-ad76-4a77-b477-e768951dcf92
Description
DecimalParts::to_decimal_stringandto_f64reassembled the little-endian 32-bit words withacc + ((part as u32 as u128) << (i * 32)). Ati == 4the shift amount equals the full width of the accumulator: debug builds panic withattempt to shift left with overflow(which aborts the process when it unwinds throughmssql-odbc'sextern "C"boundary), and release builds mask the shift toamount % 128and silently return a wrong value.The input was reachable because
read_decimal_datacapped the word count at 64 rather than 4, so a malformed server payload with 5+ words was accepted at the wire and reached the fold.Changes:
MAX_DECIMAL_INT_PARTSfrom 64 to 4 and hoist it to a module-level constant. SQL Server's maximum precision of 38 digits fits in 128 bits, and the widest decimal the TDS wire format carries is 17 bytes, so a longer payload is malformed and is now rejected with aProtocolError. The fuzzing-only cap of 10 is dropped since 4 is stricter.(length - 1) >> 2truncated, so a length of 18–20 still resolved to 4 words and was accepted while leaving 1–3 unread bytes to desynchronize the following field; the same truncation left bytes on the stream for any length whose magnitude did not cover whole words (a length of 7 read one word and dropped 2 bytes). The magnitude is now read as a single zero-padded buffer, so every declared byte is consumed and a trailing partial word is preserved — matching the length-tolerant reader on the Always Encrypted path.DecimalParts::magnitude()that returnsNoneonly when the value genuinely does not fit au128, and uses|instead of+(the words are disjoint, so+was an overflow candidate in its own right). Significance is measured rather than word count, so a zero-padded value like[12345, 0, 0, 0, 0, 0]still converts.BigUintover the same words when the value does not fit au128.DecimalPartshas public fields and is constructible from FFI (NapiDecimalParts), so the formatting paths render an oversized value exactly instead of panicking or truncating.magnitude()inmssql-odbc'snumeric_source, replacing the duplicate> 4guard odbc: mandatory source-type conversions for typed SQLGetData (P1a) [AB#47107] #217 added on the typedSQLGetDatapath (whose comment still referred to the 64-word cap this PR removes). With one shared implementation,SQL_C_CHARandSQL_C_SLONGcan no longer disagree about whether a given value has a numeric interpretation.security/encryption/cell.rsand in thefetch_convert.rstest that cited the now-guardedu128fold, or the old 64-word cap, as their justification. The checks themselves are unchanged and still valid.Tests added: the widest valid 4-word magnitude; an oversized 5-word magnitude through both
to_decimal_stringandto_f64; an oversized magnitude whose extra words are zero, asserted through both the string and typed ODBC paths; an emptyint_parts; a wire-level 5-word payload that must be rejected; a length-18 payload (one byte past the limit, the boundary that rounding down used to accept); and a length-7 payload asserting the partial trailing word is consumed and the next field reads back intact.Related Issues
Fixes #234
Checklist
cargo bfmtpassescargo bclippypassescargo btestpasses — 1710mssql-tdsand 539mssql-odbclib tests pass. The 7 failures incertificate_validator/win_tls::validateare pre-existing and unrelated: they need TLS fixtures that are deliberately not tracked in git (seemssql-tds/tests/test_certificates/README.md).DecimalParts::magnitudeis newly public, with doc comments explaining theNonecase