feat: Extend ct.rs Condition to unsigned widths -- RSA groundwork - #63
feat: Extend ct.rs Condition to unsigned widths -- RSA groundwork#63laruizlo wants to merge 16 commits into
ct.rs Condition to unsigned widths -- RSA groundwork#63Conversation
Groundwork for the RSA bigint layer: identical mask constructors for u64/u32 limbs so dual-width code is written once. - Register u32 (and i32 for symmetry) in supported_mask_type!. - Macro-generated impl for u64/u32 parity: from_bool, from_bool_var, from_lsb, from_msb, is_zero, is_not_zero, is_equal, select, mov, swap, to_bool_var (const fn except mov). - Unsigned constructions use two's-complement mask identities, not the i64 sign tricks, which are invalid for full-range unsigned values. - Ordering comparisons deliberately omitted: callers derive lt from subtraction borrow chains via from_msb. - Condition<u64>::select becomes const; is_true kept for backward compatibility; Condition<i64> untouched. - Tests: parity suites for both widths with boundary coverage, mask-canonicality asserts, and a borrow-adaptor cross-check against the < operator. Non-goals deferred: generic impl<T> refactor, Condition<u8>, is_in_list CT research question. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s_bit_set - is_negative and from_msb rustdoc now point at each other as the signed/unsigned spellings of the top-bit mask; is_bit_set and from_lsb likewise for the bit-0 case. - New is_bit_set(value, bit) on Condition<u64>/Condition<u32> for shape parity with the signed impl, delegating to from_lsb; index type follows the u32 shift-count convention of core. - Real doc text on the previously empty is_bit_set/is_negative doc comments. - Tests: is_bit_set agrees with from_lsb at bit 0 and from_msb at the top bit, and each single-bit value reports exactly its own bit, both widths. - Comment style fix in the i32 registration note. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Remove Condition<u64>::is_true, which duplicated to_bool_var under a different name and the only &self receiver in the API; its sole callers were this crate's tests. - One accessor across all widths keeps the is_* prefix meaning "data in, mask out", preserves the from_bool_var/to_bool_var boundary pair, and keeps the _var suffix warning that converting to bool exits constant-time discipline. - Migrated the u64 test call sites accordingly; no production callers existed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- i64 mask-canonicality test: select(-1, 0) must return exactly -1 or 0, catching the delete-unary-minus mutants in from_bool_var and is_bit_set that truthiness checks let survive (same shape as the unsigned suites' assert_canonical). - ct_eq_zero_bytes had no tests; added zero/nonzero coverage at first, last, and high-bit positions, killing all four of its mutants including the or-assign to and-assign fold corruption. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Formatting-only sweep of pre-existing drift in 21 files (trailing whitespace, blank-line collapses, rewraps); zero code changes (git diff -w shows only 4 blank-line deletions). Needed because rust-style.yml runs cargo fmt --all --check on every PR, which gates on files this branch never touched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ct.rs Condition to unsigned widths -- RSA groundwork
ct.rs Condition to unsigned widths -- RSA groundworkct.rs Condition to unsigned widths -- RSA groundwork
ounsworth
left a comment
There was a problem hiding this comment.
This is great groundwork!
The Condition class was clearly in need of some attention. The section "Observed during review, out of scope for this PR" lists things that are all good changes / cleanup. If you are willing to tackle those as part of this PR, that would be good improvements.
I also left a few comments below.
| supported_mask_type!(i64, u64); | ||
| // i32 is registered for width symmetry with the u64/i64 pair (it gets the generic boolean | ||
| // operator impls below); it has no inherent constructors yet: add them when a consumer needs them. | ||
| supported_mask_type!(i64, u64, u32, i32); |
There was a problem hiding this comment.
Claude Code likes to make these sorts of comments that explain what it's doing right now, but these comments seem like an odd thing to check in. Is this saying that there is unfinished work ("it has no inherent constructors yet: add them when a consumer needs them.")? Why not just add them now?
There was a problem hiding this comment.
I'll also chime in here - I think it keeps the cognitive load lower/reduces complexity to keep the implementation styles of the various Conditions consistent - either all via macro or all individually-defined. Would it be possible for i32 and i64 to be done together here in an equivalent signed_condition_impl macro?
There was a problem hiding this comment.
Yeah, I didn't want to change much in the pre-existing code without consulting with you all, but fully agree that a single style would make it much cleaner. I would vote for going all macro, and can add the signed variant as well.
| // here even though it passes a truthiness check via to_bool_var. | ||
| fn assert_canonical(c: Condition<i64>, expected: bool) { | ||
| assert_eq!(c.select(-1, 0), if expected { -1 } else { 0 }); | ||
| } |
There was a problem hiding this comment.
But I don't understand the comment. I think the comment is wrong.
I tried replacing this with
fn assert_canonical(c: Condition<i64>, expected: bool) {
// assert_eq!(c.select(-1, 0), if expected { -1 } else { 0 });
assert_eq!(c.to_bool_var(), expected)
}
and the test still passes. So it's not clear to me why this test needs to use the .select() function, or what that has to do with the raw bit mask (which I don't think is what select() is returning).
| assert_eq!(Condition::<u64>::TRUE.is_true(), true); | ||
| assert_eq!(Condition::<u64>::FALSE.is_true(), false); | ||
| assert_eq!(Condition::<u64>::TRUE.to_bool_var(), true); | ||
| assert_eq!(Condition::<u64>::FALSE.to_bool_var(), false); |
There was a problem hiding this comment.
The naming of this feels weird to me. Could we call this .to_bool() instead of .to_bool_var()? Is there some significance in the _var() part of that?
(I know this is pre-existing before this PR, but if you agree, then we could clean it up at the same time)
There was a problem hiding this comment.
Agree! It seems that Claude Code added the _var() to keep the symmetry with from_bool_var, but the reverse conversion doesn't have a const version, so it's more clear if we remove that, I think. I'll add that to the cleanup.
- replace impl Condition<i64> with signed_condition_impl!(i64, i32), mirroring unsigned_condition_impl! - give i32 the full constructor set instead of a deferred-work comment - drop or_halves: is_zero/is_not_zero now use the width-generic value | value.wrapping_neg() identity - type the signed is_bit_set index as bit: u32, matching the unsigned side - add from_lsb to the signed widths for name parity - add signed_condition_tests! generating boundary-driven test modules for i64 and i32 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- is_lt used is_negative(x - y), which overflows when the operands are more than half the range apart: debug builds panic, release builds return the opposite answer - replace it with the standard overflow-free identity: sign of x when the signs differ, sign of x - y when they agree - is_lte/is_gte become const fn by complementing the inner mask instead of using the Not operator; is_within_range follows - add boundary differentials against the native operators, far-apart regression cases, and a strided full-range i32 sweep Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- the _var suffix on from_bool_var distinguishes the runtime form from the const-generic from_bool::<VALUE>(); there is no const-generic counterpart on the output side, so the suffix distinguished nothing - no production callers, test-only churn Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rpose - rewrite the masks_are_canonical comment to name the mutant it defends against: a constructor returning 1 instead of -1 passes to_bool and then corrupts every select it feeds - select between a pattern and its complement, which differ in every bit, so the assertion holds exactly when the mask is canonical - use the same helper shape in the signed and unsigned test macros - verified by hand-mutating from_bool_var to return 1: the truthiness test passes, the canonicality tests fail Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- compress the unsigned macro preamble to the ordering-comparison rationale; the signed-trick contrast no longer applies after the is_lt fix - align the unsigned is_bit_set doc with the signed one - shorten the u64 select test comment to the mask-width fact it checks Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- write impl Condition<i64> and Condition<i32> out by hand; delete signed_condition_impl! - cargo mutants skips macro_rules! bodies, so the macro hid every signed mask identity from mutation - Condition::<$t>::is_zero in is_in_list becomes Self::is_zero, the right spelling on a concrete impl - mechanical expansion (script in the plan docs), behaviour-preserving: ct_tests unchanged at 88 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- write impl Condition<u64> and Condition<u32> out by hand; delete unsigned_condition_impl! - same rationale as the signed expansion: macro bodies are invisible to cargo mutants - package mutant census now 301 total, 284 in ct.rs, 226 on the Condition constructors (was 0) - mechanical expansion, behaviour-preserving: ct_tests unchanged at 88 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…elled out - expand the trait-registration macro into its eight impl lines; ct.rs now contains no macro_rules! - replace the stale macro-era headers: the per-width duplication exists so cargo mutants can see the mask identities, and the widths in a group must be edited together - keep the Condition<u8> TODO and the unsigned ordering-comparison rationale Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- write signed_i64/i32 and unsigned_u64/u32 test modules out by hand; delete both test macros - no mutation coverage at stake (mutants.toml excludes tests/**); this is the consistency half of the reviewers' ask - reword the group headers: the modules are hand-written now and each width pair must be edited together - 88 tests, same names, same results Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- delete i64_tests and u64_tests: every assertion they made is subsumed by signed_i64_tests and unsigned_u64_tests, measured as a zero change in the mutation outcome - fix the two copy-pasted | operators in generic_impl_tests::test_bit_xor to ^; XOR coverage otherwise lives in the per-width boolean_operators tests - test count drops 88 to 66 with no coverage loss Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- a contributor who tidies the per-width impls back into a macro would silently erase the mutation coverage they exist for - widths in a group must be changed together Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend
ct.rsConditionto unsigned widths (u64 parity, new u32)Why
Groundwork for the upcoming RSA implementation. The RSA bigint layer uses
u64limbs on 64-bit targets andu32limbs elsewhere, and needs identical constant-time mask constructors for both widths so the dual-width code is written once. Before this PRConditionsupport was uneven:Condition<i64>had the full constructor set,Condition<u64>had onlyfrom_bool/select/is_true, andCondition<u32>did not exist.This PR is deliberately separate from the RSA work so the shared security-critical
utilscode gets its own review, and the RSA PRs stay pure-RSA.What
The PR has grown through review; the final state is:
Condition<i64>,<i32>,<u64>and<u32>, each registered in the sealedSupportedMaskTypescheme (registration alone gives every width the generic boolean operator impls& | ^ !and their assign forms).TRUE/FALSEfrom_bool,from_bool_var: mask from a boolean (wrapping-sub construction)from_lsb: mask from bit 0 (parity/oddness tests)from_msb: mask from the top bit (adaptor for borrow/carry words coming out of wrapping subtraction chains:from_msb(borrow)is theltmask, no post-processing)is_bit_set,is_zero,is_not_zero,is_equalselect,mov,swap,to_boolconst fnexceptmovis_ltnow uses the standard overflow-free signed-comparison identity. The previousis_negative(x - y)construction overflowed for operands more than half the range apart (debug panic, opposite answer in release). With overflow gone, the whole comparison family (is_lt,is_lte,is_gt,is_gte,is_within_range) isconst fn.ct.rscontains nomacro_rules!. Earlier revisions macro-generated the per-width impls, butcargo mutantsparses withsynand cannot see into macro bodies, so the macro form hid every mask identity from mutation testing: theConditionconstructors produced 0 mutants. Per review feedback the widths are now individually defined, taking the package census from 75 to 301 mutants (226 on the constructors). See Verification below for the run results.CLAUDE.mdnote state that the widths in a group must be edited together. A small mechanical parity check (normalise the impls, fail on divergence) is planned as its own follow-up PR rather than growing this one.IMPORTANT / API changes relative to the pre-PR
Condition<u64>:0202982removesCondition<u64>::is_true(). It duplicated the boolean accessor under a second name and was the API's only&selfreceiver. A workspace-wide search found no production callers (its only uses were this crate's own tests, migrated in the same commit). The removal is deliberately isolated in its own commit: if reviewers prefer to keepis_true, dropping that single commit restores it without affecting the rest of the PR.bfc0b82renamesto_bool_var()toto_bool()on all widths. The_varsuffix onfrom_bool_vardistinguishes the runtime form from the const-genericfrom_bool::<VALUE>(); there is no const-generic counterpart on the output side, so the suffix distinguished nothing. No production callers; out-of-tree or in-flight code callingis_true()orto_bool_var()must switch toto_bool().Design notes for review
is_negativevia arithmetic shift, the sign-basedis_ltidentity) that has no meaning for full-range unsigned values. The unsigned versions use the standard mask identities:wrapping_subfrom a 0/1 bit, and MSB-extraction ofx | x.wrapping_neg()for the zero test.ltfrom their subtraction borrow chain and convert withfrom_msb; a word-level unsignedis_ltwould invite exactly the overflow-style misuse the signed identity has to defend against.const fnthroughout;black_boxhygiene stays where it already lives (the byte-level helpers), sinceblack_boxis not const-compatible and the mask constructors don't use it on any width.is_ltthe two operands fire on disjoint sign cases, and inselectthe mask and its complement never both pass a bit, so|and^compute the same function and no test can separate them. This is the survivor class QUALITY_AND_STYLE.md names as acceptable. The|spelling is nevertheless the contract: because tests cannot distinguish the two, they also cannot catch a functionally-equal but non-constant-time rewrite of a single width, so the mask-based shapes must be preserved exactly.Tests
One hand-written test module per width, mirroring the impls they exercise (the test macros were expanded for the same consistency reasons;
.cargo/mutants.tomlexcludestests/**, so no mutation coverage was at stake):0, 1, MAX, MAX-1, 1 << (BITS-1)unsigned and0, +/-1, MIN, MIN+1, MAX, MAX-1signed, the values where leaked cross-signedness reasoning or overflow would produce wrong masks.selectbetween a pattern and its complement, which differ in every bit, must return one of them exactly. This proves the mask is all-ones/all-zeros through the public API and kills the wrong-mask mutants a plain truthiness check passes (e.g. a constructor returning1instead of-1).<,<=,==, ranges) at every width over all boundary pairs, plus a far-apart-operand regression for the oldis_ltoverflow and a dense strided sweep of the full i32 range.from_msbof a widening-subtraction borrow word agrees with the<operator over all boundary pairs.i64_tests/u64_testsmodules are folded into the per-width modules; deleting them was measured as a zero change in the mutation outcome.ct_testsruns 66 tests; thebouncycastle-utilspackage runs 83.Verified additionally against a dual-width bigint prototype implementing the RSA phase-1 predicate/conditional set (
ct_is_zero,ct_eq,ct_ltvia sbb chain,is_odd,bit(i), select/assign/swap, conditional modular correction, masked table scan) — compiles and passes written-once againstCondition<Word>in both the native-u64 and forced-u32 lanes.Consumers
hexandbase64are the only production consumers ofConditionmasks (all via the signedis_within_range/is_in_listpaths, untouched). The byte-level helpers (ct_eq_bytes,ct_eq_zero_bytes,conditional_copy_bytes) used bycore,hmac,mlkem,mldsaand the lowmemory variants are behaviourally untouched.Pre-push verification (final tree):
cargo test --workspace: 71 suites ok, 0 failurescargo fmt --all --checkclean;cargo doc -p bouncycastle-utils --no-depsclean under#![forbid(missing_docs)]cargo mutants -p bouncycastle-utils -j 2 --timeout 20: 301 mutants: 220 caught, 15 missed, 66 unviable (macro-form baseline: 75 mutants, constructors invisible). The 15 missed:is_lt(i64, i32) andselect(all four widths), triaged above; new only in the sense that they were previously invisibleconditional_copy_bytes(disjoint masked operands), unchanged from before this PRSecret::drop(the suite exercises explicitzeroize()but never observes drop-time scrubbing), unchanged from before this PRDefault::default()replacements on constructors;Conditionhas noDefaultErr()counts flat (491/260 core-code baseline)Co-authored with Claude Code