test(fn-dsa): execute flr_emu.rs — the float backend shipped to wasm32/armv7 that nothing ever ran - #74
Merged
Merged
Conversation
…m/armv7
WHY
---
`fn-dsa-sign/src/flr.rs` picks the floating-point backend by `target_arch`
alone: flr_native.rs on x86_64/aarch64/arm64ec/riscv64, flr_emu.rs (software
binary64) on everything else -- wasm32, armv7, 32-bit x86. Nothing in this
repo executed flr_emu.rs. It is not a feature: `--features no_avx2` switches
the portable *vector* path and leaves the float backend native, so no x86_64
job could reach it however configured. The wasm rows are `cargo check` /
`wasm-pack build` only, and the aarch64/armv7 rows never run at all on PRs.
FN-DSA signing is decided bit-for-bit by these rounding rules, so a defect
here would have shipped as wrong or non-verifying signatures on wasm and
armv7 rather than as a clean failure. The correctness of that backend was
unknown in both directions. It is now measured, on both axes:
1. flr_emu_diff.rs (new, test-only). flr.rs compiles flr_emu.rs a SECOND
time under #[cfg(test)] on the native-backend arches, and the tests diff
the two backends bit-for-bit -- via encode(), so +0.0 and -0.0 are
distinguished -- over every operation of the Flr surface. This runs in
the ordinary `cargo test --workspace` rows; no emulator required.
Production dispatch is untouched: a non-test x86_64 build still compiles
flr_native.rs and only flr_native.rs.
2. A new `fn-dsa-emulated-float` CI job runs this crate's existing suite on
wasm32-wasip1 under wasmtime. wasm32 selects flr_emu.rs *and* the generic
variants of its four arch-gated helpers (lzcnt_nz/ursh/ulsh/irsh) -- the
same combination armv7 ships, and the part a host build cannot reach.
The job asserts the build really compiled flr_emu.rs before trusting a
green run, so it cannot decay into a silent no-op the way `self_test`
did when has_avx2() sent it down the AVX2 branch.
RESULT: no defect. The emulated backend agrees with the native one on every
in-domain input tested.
WHAT THE DIFFERENTIAL FOUND
---------------------------
Four inputs, all involving a negative zero, on which the two backends do NOT
agree. Every one is inherited verbatim from upstream fn-dsa v0.3.0; neither
backend is modified here. They are PINNED, not fixed -- changing either side
would change signature bytes on some target, which is not a test's call:
abs(-0.0) emu +0.0, native -0.0 (emu is the IEEE-correct one; abs() is
test-only in both backends)
sqrt(-0.0) emu +0.0, native -0.0 (native is IEEE-correct)
floor(-0.0) emu -1, native 0 (deliberate upstream behaviour, see the
comment in flr_emu.rs)
(+/-0.0) / x emu forces the quotient sign to +, native follows IEEE
Only floor() reaches production code (sampler.rs `let s = mu.floor()`); every
division in this crate is `Flr::ONE / x`, so a zero dividend cannot occur, and
abs() is called only from tests. Whether `mu` can be exactly -0.0 is NOT
established here and is left as an open question on the record.
Also pinned: expm_p63 with ccs == 1 exactly, where the implementations differ
because `Flr::ONE.mul2p63().trunc()` applies trunc() to 2^63, one past the top
of its own documented input range. A precondition violation, not a defect.
A NOTE ON THE MULTIPLICATION-TIE VECTORS
----------------------------------------
The first version of this test did not catch a one-bit change to the rounding
table inside `Flr::make()` (0xC8 -> 0x88, i.e. dropping the round-half-to-EVEN
"round up" case). That bit is reachable only through set_mul and set_div, and
a division can never produce an exact tie: with a = a0*2^u and b = b0*2^v, a0
and b0 odd, the quotient (a0/b0)*2^(u-v) has an odd denominator in lowest
terms, so it either terminates within 53 bits (exact, no rounding) or never
terminates (sticky = 1, not a tie). Multiplication is the only route, and
random operands essentially never land on an exact tie. The 16 hardcoded
operand pairs in `flr_emu_matches_native_multiplication_ties` were searched
for explicitly and cross-checked against an arbitrary-precision model; without
them that mutation left all 18 other tests in this crate green.
DETERMINISM: every randomized input is SHAKE256-derived from a hardcoded
seed. Nothing reads the clock or system entropy.
The two `#[allow]`s on the test-only `mod emu_ref` are deliberate and are
documented in flr.rs. They exist because flr_emu.rs raises two clippy
findings when compiled on x86_64 -- findings the repo's own
`cargo clippy --workspace --all-targets --all-features -- -D warnings` gate
has never surfaced, precisely because it never compiles that file. Silencing
them at the module declaration keeps flr_emu.rs byte-faithful to upstream.
…u/sqrt_emu WHY --- The differential added in 8e37de4 was RED under two of this crate's own features. `cargo test -p lib-q-fn-dsa-sign --lib --features sqrt_emu` panicked at flr_emu_diff.rs:529 (`bits_nat(Nat::NZERO.sqrt()) == NZ`), and `--features div_emu` panicked at :562; `--all-features` failed at :529. Both are regressions introduced by that commit -- flr_emu_diff.rs does not exist on origin/main (b68ed13), so the test could not have been red before. ROOT CAUSE ---------- The comment 8e37de4 put in flr.rs claimed the float backend "is *not* influenced by any Cargo feature". That is true of module SELECTION -- which of flr_native.rs / flr_emu.rs is compiled is `target_arch`-only -- and false of the selected backend's BEHAVIOUR. `div_emu` swaps flr_native's `self.0 /= other.0` for the integer `Flr::div_emu()`, and `sqrt_emu` swaps its SSE2/NEON/RISC-V sqrt opcode for `Flr::sqrt_emu()`; both exist for targets whose FPU divide/sqrt is not constant-time. Those two routines are line-for-line ports of flr_emu.rs's own `set_div`/`sqrt`: `sqrt_emu` ends in the same `make_z(0, ..)` that cannot emit a sign bit, and `div_emu` carries the same `s &= dm` zero-dividend clamp. So under those features the native side ADOPTS flr_emu's signed-zero behaviour and two of the four pinned divergences vanish -- while the test hard-coded the opposite for the native side. Believing that comment is what produced the bug, so the comment is fixed too, in the same commit. WHAT CHANGED ------------ flr.rs: the backend-selection comment now separates SELECTION (target_arch only, unchanged) from BEHAVIOUR (div_emu/sqrt_emu, native side only), names exactly what each feature swaps and what that does to signed zeros, and warns that no CI row sets either feature so a native-side claim that only holds without them will not be caught. flr_emu_diff.rs: no assertion is deleted or cfg'd out. The two feature-sensitive expectations become cfg-selected constants -- NAT_SQRT_NZERO (NZ without sqrt_emu, PZ with) and NAT_ZERO_DIV ([PZ,NZ,NZ,PZ] without div_emu, all-PZ with) -- so every pin still runs under every feature combination, and under the emu features the test now additionally asserts that the divergence really is GONE rather than merely not looking. Cfg-ing the assertions away instead would have left `--features div_emu,sqrt_emu` -- the configuration a constant-time-conscious integrator actually ships -- with the signed-zero contract unchecked. The assert messages now print the observed bits, the pinned bits and the feature state, so a future failure names its own cause. VERIFIED -------- `cargo test -p lib-q-fn-dsa-sign --lib -- --test-threads=1` under no features / div_emu / sqrt_emu / div_emu,sqrt_emu: `ok. 19 passed` each. `cargo test -p lib-q-fn-dsa-sign -p lib-q-fn-dsa-comm --all-features --lib`: `ok. 15 passed` + `ok. 6 passed` (was 14 passed; 1 failed). `cargo clippy -p lib-q-fn-dsa-sign -p lib-q-fn-dsa-comm --all-targets --all-features -- -D warnings` and `cargo fmt -p lib-q-fn-dsa-sign -- --check`: clean. The differential still bites, checked by mutation and reverted after each: * flr_emu `make()` 0xC8 -> 0x88 (drop the round-half-to-EVEN round-up case): multiplication_ties FAILED under all four feature combinations, `emu=0x46802F73BAFEB827 native=0x46802F73BAFEB828`. * flr_emu `sqrt` `make_z(0, ..)` -> `make_z(self.0 >> 63, ..)`: pinned test FAILED, "emu sqrt(-0.0) != +0.0", with and without sqrt_emu. * flr_native `sqrt_emu` made to keep the sign bit: pinned test FAILED under --features sqrt_emu, "native sqrt(-0.0) = 0x8000000000000000, pinned 0x0000000000000000 (sqrt_emu = true)" -- i.e. the new feature-branch expectation is a live check, not a rubber stamp. * flr_native `div_emu` `s & dm` -> `s & u64::MAX` (drop the clamp): pinned test FAILED under --features div_emu, "native zero-dividend div #1 = 0x8000000000000000, pinned 0x0000000000000000 (div_emu = true)". BLAST RADIUS of the original defect, stated honestly: no CI job sets either feature (`grep -rn "div_emu\|sqrt_emu" .github/ scripts/` returns nothing) and no crate forwards them (only fn-dsa-sign/Cargo.toml declares them), so this was never CI-red. It was a latent trap for anyone building the crate under its own constant-time divide/sqrt features. NOT FIXED HERE: `cargo clippy -p lib-q-fn-dsa-sign --all-targets --all-features` (that crate alone) still fails to compile at lib.rs:819, `no SHAKE256x4 in shake` -- fn-dsa-comm's `shake256x4` feature is not enabled by selecting only the dependent crate. Pre-existing, in a file this commit does not touch, and owned by the fix/shake256x4-feature branch.
…iff.rs comments The comments claimed no CI row sets div_emu/sqrt_emu, so a claim that only holds without them "will not be caught by CI." That's false: ci.yml's workspace-root `cargo clippy --all-targets --all-features` step is not `-p`-scoped, so it does compile-check both features. The accurate nuance is that no CI row actually *runs tests* under either feature -- clippy type-checks but doesn't execute -- so a runtime/behavioral claim could still slip through untested.
🔍 Pull Request SummaryGenerated: Wed Jul 29 12:54:27 UTC 2026 📋 Validation Results
✅ Overall Status: PASSED🔒 Security Checklist
📝 Review NotesPlease review the security implications of this change carefully. ✅ Automated validation passed! This PR is ready for review. |
🔒 Security Validation ReportGenerated: Wed Jul 29 12:54:33 UTC 2026 📊 Summary
✅ Overall Security Status: PASSEDAll critical security validations passed successfully. 🔍 DetailsThis report covers:
📋 Next Steps✅ Security validation passed. Code is ready for deployment. ✅ Security validation passed! This code meets all security requirements. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
flr_emu.rsis the software binary64 backend FN-DSA signing ships to wasm32, armv7 and 32-bit x86. Until this PR, nothing anywhere executed a single line of it.fn-dsa-sign/src/flr.rspicks the float backend bytarget_arch, independently of AVX2:flr_native.rsfor x86_64/aarch64/arm64ec/riscv64,flr_emu.rsfor everything else. So forcing--features no_avx2on x86_64 does not reach it — that flag selects the portable vector path, while the float backend is chosen by architecture. It was compiled by the wasm/armv7cargo checkgates and executed by nothing, on any machine, since import.Falcon/FN-DSA signing is sensitive to floating-point behaviour, so a defect here would plausibly yield wrong or non-verifying signatures rather than a clean failure.
What this adds
fn-dsa-sign/src/flr_emu_diff.rs— a bit-exact differential of the emulated backend against the native one, comparing throughencode()(raw binary64 bit patterns, so+0.0and-0.0are distinguished) across the wholepub(crate)surface: constants, conversions, rounding to integer, rounding ties, multiplication ties,expm_p63, and pseudo-random ops with a fixed seed.Plus a
fn-dsa-emulated-floatCI job so it actually runs, wired intofinal-validation.Result:
flr_emu.rsagrees withflr_native.rsbit-for-bit on everything tested, except a small set of signed-zero cases which are explicitly pinned rather than papered over. That is a good outcome — the backend was correct, and is now guarded.Two defects found in review and fixed here
The new test was red under the crate's own features.
fn-dsa-signdeclaresdiv_emuandsqrt_emu, which are not vector flags — they swapflr_native'sset_divandsqrtfor integer emulations that reproduceflr_emu's signed-zero behaviour. The first draft hardcoded the opposite for the native side:18 passed; 1 failedunder either flag. Now19 passed; 0 failedunder all four combinations (none /div_emu/sqrt_emu/ both).A false claim in a production comment — the same defect class this PR exists to fix. The comment said "no CI row sets either feature today". False:
ci.yml:76runscargo clippy --all-targets --all-featuresunscoped at the workspace root, which enables both. The accurate statement, now in the file, is that CI compile-checks these features via that clippy pass but never executes tests under them.Verified
All four feature combinations
19 passed; 0 failed. Mutation-checked: defects injected intoflr_emu.rsare caught. Neitherflr_emu.rsnorflr_native.rsis modified — production dispatch is untouched, x86_64 still selectsflr_nativein every non-test build.Not checked
flr_emumatchesflr_nativeon x86_64; it does not prove behaviour on the targets that actually ship it. A qemu/wasm execution row remains the right follow-up.div_emu/sqrt_emuare still only compile-checked by CI, never run. This PR does not add a row that executes them.