Use native bytevector-ieee-double accessors for the float path
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.
README.md | 11 +++++++----
docs/wire.md | 15 ++++++++-------
src/sigil/wire.sgl | 124 +++++++++++++++++++++++-----------------------------------------------------------------------------------------------------
test/test-wire.sgl | 24 +++++++++++++++++++++++-
4 files changed, 61 insertions(+), 113 deletions(-)README.mdmodified
### FloatsDoubles are stored as their raw 64-bit IEEE-754 bit pattern, little-endian.`+inf`, `-inf` and `NaN` round-trip bit-exactly. Note that Sigil cannotdistinguish `-0.0` from `+0.0` (they compare equal under every predicate andprint identically), so a negative zero encodes as `+0.0`.Doubles are stored as their raw 64-bit IEEE-754 bit pattern, little-endian, viathe native `bytevector-ieee-double-{ref,set!}` accessors. Every double — normal,subnormal, `±0.0`, `±inf`, `NaN` — round-trips **bit-exactly**, including the signbit of `-0.0` (even though Sigil predicates cannot themselves distinguish `-0.0`from `+0.0`). On decode, the accessor canonicalizes any hostile bit pattern to asafe quiet-NaN flonum, so a malicious float body can never produce atype-confused value.## Securitydocs/wire.mdmodified
the top bit of the last (most significant) byte.- `+inf` = `7FF0000000000000`, `-inf` = `FFF0000000000000`.- `NaN` is written canonically as `7FF8000000000000` (any incoming NaN normalizes to this on encode); on decode any payload with exponent field all-1 and non-zero fraction is a NaN.- `-0.0` is not represented distinctly by the reference implementation (Sigil cannot distinguish it from `+0.0`); it encodes as `+0.0` = `0000000000000000`. A decoder that receives `8000000000000000` should still produce `-0.0` where the host supports it.- `NaN`: any payload with the exponent field all-ones and a non-zero fraction is a NaN. The reference implementation writes whatever bits the host double carries and, on decode, canonicalizes a bit pattern that would collide with the runtime's tagged-value space to a safe quiet NaN (see below).- `-0.0` = `8000000000000000` is represented distinctly and round-trips bit-exactly (the reference implementation uses the host's native IEEE accessor, which preserves the sign bit — even though the language's numeric predicates cannot distinguish `-0.0` from `+0.0`).Example: `3.14 → 03 1F 85 EB 51 B8 1E 09 40` (bytes are `0x40091EB851EB851F` LE).src/sigil/wire.sglmodified
;; ========== Varint (unsigned LEB128) ========== ;; NOTE: the varint and float-bit paths use only arithmetic (quotient / ;; remainder / * / +), never bitwise-and / bitwise-ior / arithmetic-shift. ;; Sigil's bitwise/shift primitives mis-handle values straddling the ;; fixnum<->bignum boundary (e.g. round-tripping 2^62 or 7^500 through ;; shift+ior corrupts the value), whereas plain integer arithmetic is ;; correct for arbitrary-precision integers. See the wire-format topic note. ;; NOTE: the varint path uses only arithmetic (quotient / remainder / * / +), ;; never bitwise-and / bitwise-ior / arithmetic-shift. Older Sigil bitwise/ ;; shift primitives mis-handle values straddling the fixnum<->bignum boundary ;; (round-tripping 2^62 or 7^500 through shift+ior corrupted the value); that ;; VM bug is fixed as of 0.17.18, but the arithmetic form is kept because it ;; is correct on ANY sigil (incl. older and wasm builds) at no real cost. ;; See the wire-format topic note. ;; Write a non-negative integer as unsigned LEB128 to a port. (define (write-uvarint u port) (quotient u 2) (- (- (quotient u 2)) 1))) ;; ========== Float <-> IEEE-754 bits ========== ;; ========== Float <-> IEEE-754 bytes ========== ;; ;; Sigil's numeric tower is exact integers (incl. bignums) and inexact ;; doubles — there are NO exact rationals — so `(exact <float>)` errors on a ;; non-integer. We therefore derive the 64-bit IEEE-754 representation with ;; float arithmetic + exact-integer ops, never touching a rational. Every ;; scaling factor stays within the representable double range (|exponent| <= ;; 1022 per step) so no intermediate overflows to infinity. (define POW2-52 (expt 2 52)) ; 4503599627370496 (define POW2-63 (expt 2 63)) ; sign-bit place value ;; Sigil has no infinity/NaN literals, but doubles CAN be inf/NaN (e.g. from ;; overflow). Build them once, by arithmetic, to reconstruct on decode. (define WIRE-POS-INF (expt 2.0 2000)) ; overflows to +inf (define WIRE-NEG-INF (- (expt 2.0 2000))) ; -inf (define WIRE-NAN (- WIRE-POS-INF WIRE-POS-INF)) ; inf - inf = NaN ;; Assemble sign/biased-exponent/fraction into a 64-bit unsigned integer. (define (pack-bits sign biased frac) (+ (* sign POW2-63) (* biased POW2-52) frac)) ;; Encode a double as its unsigned 64-bit IEEE-754 bit pattern. ;; NaN is checked BEFORE zero: Sigil's `(= nan 0.0)` returns #t, so a ;; zero-first test would mis-encode NaN as +0.0. (define (double->bits x) (cond ;; NaN: canonical quiet NaN (0x7FF8000000000000); fraction = 2^51. ((nan? x) (pack-bits 0 2047 (quotient POW2-52 2))) ;; +/- infinity. ((not (finite? x)) (if (< x 0.0) (pack-bits 1 2047 0) (pack-bits 0 2047 0))) ;; Zero (positive and negative zero both encode as +0.0; Sigil does not ;; distinguish them under `=`, `eqv?`, `equal?`, or `number->string`). ((= x 0.0) 0) (else (let* ((neg (< x 0.0)) (sign (if neg 1 0)) (ax (abs x))) (if (< ax (expt 2.0 -1022)) ;; Subnormal: significand = round(ax * 2^1074), scaled in two ;; representable steps (2^1074 itself overflows a double). (let ((frac (exact (round (* (* ax (expt 2.0 1022)) (expt 2.0 52)))))) (pack-bits sign 0 frac)) ;; Normal: find unbiased exponent e with 2^e <= ax < 2^(e+1). (let loop ((e (exact (floor (/ (log ax) (log 2.0)))))) (cond ((<= (expt 2.0 (+ e 1)) ax) (loop (+ e 1))) ((> (expt 2.0 e) ax) (loop (- e 1))) (else ;; ax/2^e is exactly a double in [1,2); *2^52 is an exact ;; integer significand in [2^52, 2^53). (let* ((sig (exact (round (* (/ ax (expt 2.0 e)) (expt 2.0 52))))) (frac (- sig POW2-52)) (biased (+ e 1023))) (pack-bits sign biased frac)))))))))) ;; Decode an unsigned 64-bit IEEE-754 bit pattern to a double. (define (bits->double bits) (let* ((sign (quotient bits POW2-63)) (rest (remainder bits POW2-63)) (biased (quotient rest POW2-52)) (frac (remainder rest POW2-52))) (cond ;; Inf / NaN carry their own sign; return directly. ((= biased 2047) (cond ((not (= frac 0)) WIRE-NAN) ((= sign 1) WIRE-NEG-INF) (else WIRE-POS-INF))) (else (let ((mag (cond ;; Zero / subnormal. ((= biased 0) (if (= frac 0) 0.0 (* frac (expt 2.0 -1074)))) ;; Normal. (else (* (+ frac POW2-52) (expt 2.0 (- biased 1075))))))) (if (= sign 1) (- mag) mag)))))) ;; The wire float is the raw 8-byte IEEE-754 double, LITTLE-ENDIAN (fixed by ;; the format, independent of host byte order). Sigil-stdlib's native ;; `bytevector-ieee-double-{ref,set!}` do the exact conversion, so every ;; double — normal, subnormal, +/-0.0, +/-inf, NaN — round-trips bit-exactly. ;; Crucially for the trust boundary, `-ref` is NaN-box-safe: it canonicalizes ;; any hostile bit pattern (including ones that would collide with the VM's ;; immediate tag space) to a genuine quiet-NaN flonum, never a type-confused ;; Value. It also bounds-checks the offset/length itself. ;; ========== Encoding ========== (error "wire-encode: value is not representable on the wire" value)))) (define (encode-float value port) (let ((bits (double->bits value))) ;; 8 bytes, little-endian (arithmetic, not bitwise). (let loop ((i 0)) (when (< i 8) (write-u8 (remainder (quotient bits (expt 256 i)) 256) port) (loop (+ i 1)))))) ;; Raw 8-byte IEEE-754 double, little-endian (exact bits, incl. -0.0). (let ((bv (make-bytevector 8 0))) (bytevector-ieee-double-set! bv 0 value 'little) (write-bytevector bv port))) (define (encode-list value port) ;; Walk once: verify the list is proper and count length, collecting the (cur-take! c n))) (define (decode-float c) (cur-need! c 8) (let loop ((i 0) (bits 0)) (if (= i 8) (bits->double bits) (loop (+ i 1) (+ bits (* (cur-u8! c) (expt 256 i))))))) ;; cur-take! bounds-checks the 8 bytes; bytevector-ieee-double-ref ;; canonicalizes any hostile bit pattern to a safe flonum. (bytevector-ieee-double-ref (cur-take! c 8) 0 'little)) (define (decode-char c) (let ((cp (read-uvarint c LEN-VARINT-MAX-BYTES)))test/test-wire.sglmodified
(test "+inf stays infinite" (assert-true (infinite? (rt (expt 2.0 2000))))) (test "-inf stays negative" (assert-true (< (rt (- (expt 2.0 2000))) 0.0))) (test "NaN stays NaN" (assert-true (nan? (rt (- (expt 2.0 2000) (expt 2.0 2000))))))) (assert-true (nan? (rt (- (expt 2.0 2000) (expt 2.0 2000)))))) ;; The native IEEE accessor preserves the sign bit, so -0.0 now round-trips ;; bit-exactly. equal? cannot distinguish +/-0.0, so compare the wire bytes: ;; -0.0 and +0.0 must encode to DIFFERENT frames (sign bit set only for -0.0). (test "-0.0 round-trips (decodes to a zero)" (assert-equal 0.0 (rt (- 0.0)))) (test "-0.0 preserves its sign bit on the wire (distinct from +0.0)" (assert-false (equal? (wire-encode (- 0.0)) (wire-encode 0.0)))))(test-group "decode - hostile float bit patterns (NaN-box safety)" ;; A float body may carry any 8 bytes. bytevector-ieee-double-ref canonicalizes ;; bit patterns that would collide with the VM's immediate tag space to a safe ;; quiet-NaN flonum — never a type-confused Value, crash, or OOB read. (test "tag-zone NaN 0x7FFC... reads as a safe NaN flonum" (let ((r (guard (e (#t 'clean-error)) (wire-decode (frame #x03 0 0 0 0 0 0 #xFC #x7F))))) (assert-true (or (eq? r 'clean-error) (and (number? r) (nan? r)))))) (test "all-ones float bits read as a safe NaN flonum" (let ((r (guard (e (#t 'clean-error)) (wire-decode (frame #x03 #xFF #xFF #xFF #xFF #xFF #xFF #xFF #xFF))))) (assert-true (or (eq? r 'clean-error) (and (number? r) (nan? r)))))) (test "float body truncated to 4 bytes errors cleanly" (assert-true (raises? (lambda () (wire-decode (frame #x03 0 0 0 0)))))))(test-group "round-trip - strings" (test "empty" (assert-equal "" (rt "")))