Commit81ba8a6eRecorded21 Jul 2026Repositorysigil-wire
Use native bytevector-ieee-double accessors for the float path
Message
Swap the float encode/decode from the pure-arithmetic IEEE-754 bit derivation to sigil-stdlib's native bytevector-ieee-double-ref/set! (added in 0.17.18 alongside the bignum bitwise-correctness fix):
- encode-float: bytevector-ieee-double-set! ... 'little; decode-float: bytevector-ieee-double-ref (cur-take! c 8) 0 'little. Deletes ~85 lines (double->bits/bits->double/pack-bits/POW2/WIRE-INF/NAN).
- -0.0 now round-trips bit-exactly (the accessor preserves the sign bit), superseding the earlier collapse-to-+0.0 limitation.
- NaN-box safety on decode: -ref canonicalizes any hostile bit pattern to a safe quiet-NaN flonum, so a malicious float body can never yield a type-confused value. Added decode tests (tag-zone + all-ones bits).
- Varint path kept as pure arithmetic on purpose: correct on any sigil (incl. older/wasm builds), the bitwise fix notwithstanding.
Verified 126/126 against a dev sigil (0.17.18-dev+0bb453df) via dev-redirects.sgl. README + docs/wire.md updated.
Changed
README.md | 11 +++++++----
docs/wire.md | 15 ++++++++-------
src/sigil/wire.sgl | 124 +++++++++++++++++++++++-----------------------------------------------------------------------------------------------------
test/test-wire.sgl | 24 +++++++++++++++++++++++-
4 files changed, 61 insertions(+), 113 deletions(-)Diff
README.mdmodified
@@ -110,10 +110,13 @@ signed to unsigned so small negatives stay short: `n >= 0 → 2n`, `n < 0 →
110
111
### Floats 112
−113
Doubles are stored as their raw 64-bit IEEE-754 bit pattern, little-endian.−114
`+inf`, `-inf` and `NaN` round-trip bit-exactly. Note that Sigil cannot−115
distinguish `-0.0` from `+0.0` (they compare equal under every predicate and−116
print identically), so a negative zero encodes as `+0.0`.+113
Doubles are stored as their raw 64-bit IEEE-754 bit pattern, little-endian, via+114
the native `bytevector-ieee-double-{ref,set!}` accessors. Every double — normal,+115
subnormal, `±0.0`, `±inf`, `NaN` — round-trips **bit-exactly**, including the sign+116
bit of `-0.0` (even though Sigil predicates cannot themselves distinguish `-0.0`+117
from `+0.0`). On decode, the accessor canonicalizes any hostile bit pattern to a+118
safe quiet-NaN flonum, so a malicious float body can never produce a+119
type-confused value. 120
121
## Security 122
docs/wire.mdmodified
@@ -104,13 +104,14 @@ Eight bytes holding the raw IEEE-754 binary64 bit pattern, little-endian. Sign i
104
the top bit of the last (most significant) byte. 105
106
- `+inf` = `7FF0000000000000`, `-inf` = `FFF0000000000000`.−107
- `NaN` is written canonically as `7FF8000000000000` (any incoming NaN−108
normalizes to this on encode); on decode any payload with exponent field all-1−109
and non-zero fraction is a NaN.−110
- `-0.0` is not represented distinctly by the reference implementation (Sigil−111
cannot distinguish it from `+0.0`); it encodes as `+0.0` = `0000000000000000`.−112
A decoder that receives `8000000000000000` should still produce `-0.0` where−113
the host supports it.+107
- `NaN`: any payload with the exponent field all-ones and a non-zero fraction is+108
a NaN. The reference implementation writes whatever bits the host double+109
carries and, on decode, canonicalizes a bit pattern that would collide with the+110
runtime's tagged-value space to a safe quiet NaN (see below).+111
- `-0.0` = `8000000000000000` is represented distinctly and round-trips+112
bit-exactly (the reference implementation uses the host's native IEEE accessor,+113
which preserves the sign bit — even though the language's numeric predicates+114
cannot distinguish `-0.0` from `+0.0`). 115
116
Example: `3.14 → 03 1F 85 EB 51 B8 1E 09 40` (bytes are `0x40091EB851EB851F` LE). 117
src/sigil/wire.sglmodified
@@ -150,12 +150,13 @@
150
151
;; ========== Varint (unsigned LEB128) ========== 152
−153
;; NOTE: the varint and float-bit paths use only arithmetic (quotient /−154
;; remainder / * / +), never bitwise-and / bitwise-ior / arithmetic-shift.−155
;; Sigil's bitwise/shift primitives mis-handle values straddling the−156
;; fixnum<->bignum boundary (e.g. round-tripping 2^62 or 7^500 through−157
;; shift+ior corrupts the value), whereas plain integer arithmetic is−158
;; correct for arbitrary-precision integers. See the wire-format topic note.+153
;; NOTE: the varint path uses only arithmetic (quotient / remainder / * / +),+154
;; never bitwise-and / bitwise-ior / arithmetic-shift. Older Sigil bitwise/+155
;; shift primitives mis-handle values straddling the fixnum<->bignum boundary+156
;; (round-tripping 2^62 or 7^500 through shift+ior corrupted the value); that+157
;; VM bug is fixed as of 0.17.18, but the arithmetic form is kept because it+158
;; is correct on ANY sigil (incl. older and wasm builds) at no real cost.+159
;; See the wire-format topic note. 160
161
;; Write a non-negative integer as unsigned LEB128 to a port. 162
(define (write-uvarint u port)@@ -180,90 +181,16 @@
181
(quotient u 2) 182
(- (- (quotient u 2)) 1))) 183
−183
;; ========== Float <-> IEEE-754 bits ==========+184
;; ========== Float <-> IEEE-754 bytes ========== 185
;;−185
;; Sigil's numeric tower is exact integers (incl. bignums) and inexact−186
;; doubles — there are NO exact rationals — so `(exact <float>)` errors on a−187
;; non-integer. We therefore derive the 64-bit IEEE-754 representation with−188
;; float arithmetic + exact-integer ops, never touching a rational. Every−189
;; scaling factor stays within the representable double range (|exponent| <=−190
;; 1022 per step) so no intermediate overflows to infinity.−191
−192
(define POW2-52 (expt 2 52)) ; 4503599627370496−193
(define POW2-63 (expt 2 63)) ; sign-bit place value−194
−195
;; Sigil has no infinity/NaN literals, but doubles CAN be inf/NaN (e.g. from−196
;; overflow). Build them once, by arithmetic, to reconstruct on decode.−197
(define WIRE-POS-INF (expt 2.0 2000)) ; overflows to +inf−198
(define WIRE-NEG-INF (- (expt 2.0 2000))) ; -inf−199
(define WIRE-NAN (- WIRE-POS-INF WIRE-POS-INF)) ; inf - inf = NaN−200
−201
;; Assemble sign/biased-exponent/fraction into a 64-bit unsigned integer.−202
(define (pack-bits sign biased frac)−203
(+ (* sign POW2-63) (* biased POW2-52) frac))−204
−205
;; Encode a double as its unsigned 64-bit IEEE-754 bit pattern.−206
;; NaN is checked BEFORE zero: Sigil's `(= nan 0.0)` returns #t, so a−207
;; zero-first test would mis-encode NaN as +0.0.−208
(define (double->bits x)−209
(cond−210
;; NaN: canonical quiet NaN (0x7FF8000000000000); fraction = 2^51.−211
((nan? x) (pack-bits 0 2047 (quotient POW2-52 2)))−212
;; +/- infinity.−213
((not (finite? x))−214
(if (< x 0.0) (pack-bits 1 2047 0) (pack-bits 0 2047 0)))−215
;; Zero (positive and negative zero both encode as +0.0; Sigil does not−216
;; distinguish them under `=`, `eqv?`, `equal?`, or `number->string`).−217
((= x 0.0) 0)−218
(else−219
(let* ((neg (< x 0.0))−220
(sign (if neg 1 0))−221
(ax (abs x)))−222
(if (< ax (expt 2.0 -1022))−223
;; Subnormal: significand = round(ax * 2^1074), scaled in two−224
;; representable steps (2^1074 itself overflows a double).−225
(let ((frac (exact (round (* (* ax (expt 2.0 1022))−226
(expt 2.0 52))))))−227
(pack-bits sign 0 frac))−228
;; Normal: find unbiased exponent e with 2^e <= ax < 2^(e+1).−229
(let loop ((e (exact (floor (/ (log ax) (log 2.0))))))−230
(cond−231
((<= (expt 2.0 (+ e 1)) ax) (loop (+ e 1)))−232
((> (expt 2.0 e) ax) (loop (- e 1)))−233
(else−234
;; ax/2^e is exactly a double in [1,2); *2^52 is an exact−235
;; integer significand in [2^52, 2^53).−236
(let* ((sig (exact (round (* (/ ax (expt 2.0 e))−237
(expt 2.0 52)))))−238
(frac (- sig POW2-52))−239
(biased (+ e 1023)))−240
(pack-bits sign biased frac))))))))))−241
−242
;; Decode an unsigned 64-bit IEEE-754 bit pattern to a double.−243
(define (bits->double bits)−244
(let* ((sign (quotient bits POW2-63))−245
(rest (remainder bits POW2-63))−246
(biased (quotient rest POW2-52))−247
(frac (remainder rest POW2-52)))−248
(cond−249
;; Inf / NaN carry their own sign; return directly.−250
((= biased 2047)−251
(cond−252
((not (= frac 0)) WIRE-NAN)−253
((= sign 1) WIRE-NEG-INF)−254
(else WIRE-POS-INF)))−255
(else−256
(let ((mag (cond−257
;; Zero / subnormal.−258
((= biased 0)−259
(if (= frac 0)−260
0.0−261
(* frac (expt 2.0 -1074))))−262
;; Normal.−263
(else−264
(* (+ frac POW2-52)−265
(expt 2.0 (- biased 1075)))))))−266
(if (= sign 1) (- mag) mag))))))+186
;; The wire float is the raw 8-byte IEEE-754 double, LITTLE-ENDIAN (fixed by+187
;; the format, independent of host byte order). Sigil-stdlib's native+188
;; `bytevector-ieee-double-{ref,set!}` do the exact conversion, so every+189
;; double — normal, subnormal, +/-0.0, +/-inf, NaN — round-trips bit-exactly.+190
;; Crucially for the trust boundary, `-ref` is NaN-box-safe: it canonicalizes+191
;; any hostile bit pattern (including ones that would collide with the VM's+192
;; immediate tag space) to a genuine quiet-NaN flonum, never a type-confused+193
;; Value. It also bounds-checks the offset/length itself. 194
195
;; ========== Encoding ========== 196
@@ -346,12 +273,10 @@
273
(error "wire-encode: value is not representable on the wire" value)))) 274
275
(define (encode-float value port)−349
(let ((bits (double->bits value)))−350
;; 8 bytes, little-endian (arithmetic, not bitwise).−351
(let loop ((i 0))−352
(when (< i 8)−353
(write-u8 (remainder (quotient bits (expt 256 i)) 256) port)−354
(loop (+ i 1))))))+276
;; Raw 8-byte IEEE-754 double, little-endian (exact bits, incl. -0.0).+277
(let ((bv (make-bytevector 8 0)))+278
(bytevector-ieee-double-set! bv 0 value 'little)+279
(write-bytevector bv port))) 280
281
(define (encode-list value port) 282
;; Walk once: verify the list is proper and count length, collecting the@@ -530,12 +455,9 @@
455
(cur-take! c n))) 456
457
(define (decode-float c)−533
(cur-need! c 8)−534
(let loop ((i 0) (bits 0))−535
(if (= i 8)−536
(bits->double bits)−537
(loop (+ i 1)−538
(+ bits (* (cur-u8! c) (expt 256 i)))))))+458
;; cur-take! bounds-checks the 8 bytes; bytevector-ieee-double-ref+459
;; canonicalizes any hostile bit pattern to a safe flonum.+460
(bytevector-ieee-double-ref (cur-take! c 8) 0 'little)) 461
462
(define (decode-char c) 463
(let ((cp (read-uvarint c LEN-VARINT-MAX-BYTES)))test/test-wire.sglmodified
@@ -77,7 +77,29 @@
77
(test "+inf stays infinite" (assert-true (infinite? (rt (expt 2.0 2000))))) 78
(test "-inf stays negative" (assert-true (< (rt (- (expt 2.0 2000))) 0.0))) 79
(test "NaN stays NaN"−80
(assert-true (nan? (rt (- (expt 2.0 2000) (expt 2.0 2000)))))))+80
(assert-true (nan? (rt (- (expt 2.0 2000) (expt 2.0 2000))))))+81
;; The native IEEE accessor preserves the sign bit, so -0.0 now round-trips+82
;; bit-exactly. equal? cannot distinguish +/-0.0, so compare the wire bytes:+83
;; -0.0 and +0.0 must encode to DIFFERENT frames (sign bit set only for -0.0).+84
(test "-0.0 round-trips (decodes to a zero)"+85
(assert-equal 0.0 (rt (- 0.0))))+86
(test "-0.0 preserves its sign bit on the wire (distinct from +0.0)"+87
(assert-false (equal? (wire-encode (- 0.0)) (wire-encode 0.0)))))+88
+89
(test-group "decode - hostile float bit patterns (NaN-box safety)"+90
;; A float body may carry any 8 bytes. bytevector-ieee-double-ref canonicalizes+91
;; bit patterns that would collide with the VM's immediate tag space to a safe+92
;; quiet-NaN flonum — never a type-confused Value, crash, or OOB read.+93
(test "tag-zone NaN 0x7FFC... reads as a safe NaN flonum"+94
(let ((r (guard (e (#t 'clean-error))+95
(wire-decode (frame #x03 0 0 0 0 0 0 #xFC #x7F)))))+96
(assert-true (or (eq? r 'clean-error) (and (number? r) (nan? r))))))+97
(test "all-ones float bits read as a safe NaN flonum"+98
(let ((r (guard (e (#t 'clean-error))+99
(wire-decode (frame #x03 #xFF #xFF #xFF #xFF #xFF #xFF #xFF #xFF)))))+100
(assert-true (or (eq? r 'clean-error) (and (number? r) (nan? r))))))+101
(test "float body truncated to 4 bytes errors cleanly"+102
(assert-true (raises? (lambda () (wire-decode (frame #x03 0 0 0 0))))))) 103
104
(test-group "round-trip - strings" 105
(test "empty" (assert-equal "" (rt "")))