Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions mutants/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ Two supported forms:
- `kind = "line"`: mutant text from `missed.txt` or `timeout.txt`, with the
leading `file:line:col:` locator stripped. Locator-free entries keep matching
when unrelated edits shift line numbers. Matching is scoped to the entry's
`file`, so identical mutant text in another module is not suppressed.
`file`, so identical mutant text in another module is not suppressed. When
the same mutant text appears at several sites in one file and only some are
reviewed, add `line = <n>` (from the locator) to pin the entry to its site;
the hygiene check fails when the pinned line drifts, forcing a re-review.
- `kind = "function"`: regex passed to cargo-mutants as `--exclude-re`.

Function suppressions are broad and can hide future behavior. Prefer `line`
Expand All @@ -79,11 +82,17 @@ unless a whole function is genuinely dead or permanently equivalent.
Every suppression must include:

- `kind = "line"` or `kind = "function"`
- `category = "equivalent"` or `category = "dead"`
- `category = "equivalent"`, `category = "dead"`, or `category = "timeout"`
- `file`
- `mutant` or `pattern`
- `justification`
- `reviewer`

Categories are matched asymmetrically: `equivalent` and `dead` entries prove
the mutant cannot change observable behavior, so they cover survivors and
(flaky) timeouts alike; `timeout` entries only prove the mutant hangs the
suite, so they cover `timeout.txt` findings and never excuse a mutant that ran
to completion and survived.

The gate validates these fields before scoring. Suppression hygiene is enforced
by CI with `mutation_gate.py orphans`.
317 changes: 316 additions & 1 deletion mutants/suppressions.toml

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions salt/src/proof/prover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,65 @@ mod tests {
assert!(unordered.check(&data, root).is_ok());
}

/// Contract pin: proof construction is canonical regardless of key
/// order, including strictly descending input with no ascending pair.
#[test]
fn test_create_canonicalizes_descending_keys() {
let (store, salt_key, value, root) = setup_single_key_proof_case();
let neighbor_slot = if salt_key.slot_id() == 0 {
1
} else {
salt_key.slot_id() - 1
};
let neighbor_key = SaltKey::from((salt_key.bucket_id(), neighbor_slot));
let (high, low) = if salt_key > neighbor_key {
(salt_key, neighbor_key)
} else {
(neighbor_key, salt_key)
};

let descending = SaltProof::create([high, low], &store).unwrap();
let ascending = SaltProof::create([low, high], &store).unwrap();

assert_eq!(
descending.parents_commitments,
ascending.parents_commitments
);
assert_eq!(descending.levels, ascending.levels);

let data = [
(neighbor_key, store.value(neighbor_key).unwrap()),
(salt_key, value),
]
.into();
assert!(descending.check(&data, root).is_ok());
}

/// The first data bucket must be treated as a data bucket: once it is
/// expanded, proofs over it need its real subtree level, not the fixed
/// metadata-bucket level.
#[test]
fn test_proof_for_expanded_first_data_bucket_uses_subtree_levels() {
let store = MemStore::new();
let bucket_id = KV_BUCKET_OFFSET as BucketId;
let mut state = EphemeralSaltState::new(&store);
let mut rehash_updates = StateUpdates::default();
state
.shi_rehash(bucket_id, 0, 512, &mut rehash_updates)
.unwrap();
store.update_state(rehash_updates.clone());
let mut trie = StateRoot::new(&store);
let (root, trie_updates) = trie.update_fin(&rehash_updates).unwrap();
store.update_trie(trie_updates);

let key = SaltKey::from((bucket_id, 0));
let proof = SaltProof::create([key], &store).unwrap();
assert_eq!(proof.levels[&bucket_id], 2);

let data = [(key, None)].into();
assert!(proof.check(&data, root).is_ok());
}

#[test]
fn test_serde_commitment_rejects_invalid_compressed_point() {
let bytes = [0xffu8; 32];
Expand Down
17 changes: 17 additions & 0 deletions salt/src/proof/salt_witness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,23 @@ mod tests {
);
}

/// Contract pin: a witness must never claim direct-lookup ability; the
/// caller falls back to the SHI search over witnessed slots.
#[test]
fn test_plain_value_fast_is_unsupported() {
let witness = SaltWitness {
kvs: BTreeMap::new(),
proof: create_mock_proof(),
};

assert_eq!(
witness.plain_value_fast(b"any-key"),
Err(SaltError::UnsupportedOperation {
operation: "SaltWitness::plain_value_fast"
})
);
}

#[test]
fn test_bucket_used_slots_meta_bucket_returns_zero_without_metadata() {
let witness = SaltWitness {
Expand Down
44 changes: 44 additions & 0 deletions salt/src/proof/witness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,50 @@ mod tests {
);
}

/// A witness built for a block whose replay itself expands a bucket must
/// carry everything the stateless re-execution needs: the old-capacity
/// filter on recorded reads has to keep exactly the pre-state slots.
#[test]
#[cfg(not(feature = "test-bucket-resize"))]
fn test_witness_replay_across_replay_triggered_expansion() {
use crate::state::hasher::tests::get_same_bucket_test_keys;

let keys = get_same_bucket_test_keys();
let store = MemStore::new();
let initial: BTreeMap<_, _> = keys[..200]
.iter()
.map(|k| (k.clone(), Some(k.clone())))
.collect();
let mut state = EphemeralSaltState::new(&store);
let updates = state.update_fin(&initial).unwrap();
store.update_state(updates.clone());
let mut trie = StateRoot::new(&store);
let (_, trie_updates) = trie.update_fin(&updates).unwrap();
store.update_trie(trie_updates);

// 60 more inserts push the bucket past the 80% load factor, so the
// replay performed during witness creation expands it to 512 slots.
let block_updates: BTreeMap<_, _> = keys[200..]
.iter()
.map(|k| (k.clone(), Some(k.clone())))
.collect();
let lookups = vec![keys[0].clone(), b"witness-missing-key".to_vec()];
let witness = Witness::create([], &lookups, &block_updates, &store).unwrap();
witness.verify().unwrap();

let mut full_state = EphemeralSaltState::new(&store).cache_read();
let full_updates = full_state.update_fin(block_updates.iter()).unwrap();
let mut witness_state = EphemeralSaltState::new(&witness).cache_read();
let witness_updates = witness_state.update_fin(block_updates.iter()).unwrap();
assert_eq!(witness_updates, full_updates);

let (full_root, _) = StateRoot::new(&store).update_fin(&full_updates).unwrap();
let (witness_root, _) = StateRoot::new(&witness)
.update_fin(&witness_updates)
.unwrap();
assert_eq!(witness_root, full_root);
}

#[test]
fn test_witness_from_salt_witness_excludes_metadata_direct_lookup() {
let bucket_id = NUM_META_BUCKETS as BucketId;
Expand Down
54 changes: 34 additions & 20 deletions salt/src/state/ahash/fallback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,6 @@ fn read_small(data: &[u8]) -> [u64; 2] {
}
}

const PI: [u128; 2] = [
0x243f6a8885a308d313198a2e03707344,
0xa4093822299f31d0082efa98ec4e6c89,
];

const PI2: [u64; 4] = [
0x4528_21e6_38d0_1377,
0xbe54_66cf_34e9_0c6c,
Expand Down Expand Up @@ -69,21 +64,6 @@ pub struct DeterministicHasher {
}

impl DeterministicHasher {
Comment thread
vincent-k2026 marked this conversation as resolved.
#[inline]
pub fn new_with_keys(key1: u128, key2: u128) -> Self {
let key1 = key1 ^ PI[0];
let key2 = key2 ^ PI[1];

let key1_parts: [u64; 2] = [key1 as u64, (key1 >> 64) as u64];
let key2_parts: [u64; 2] = [key2 as u64, (key2 >> 64) as u64];

Self {
buffer: key1_parts[0],
pad: key1_parts[1],
extra_keys: key2_parts,
}
}

#[inline(always)]
fn update(&mut self, new_data: u64) {
self.buffer = folded_multiply(new_data ^ self.buffer, MULTIPLE);
Expand Down Expand Up @@ -181,6 +161,40 @@ impl core::hash::BuildHasher for RandomState {
#[cfg(test)]
mod tests {
use super::*;
use core::hash::{BuildHasher, Hasher};

/// The integer `write_*` overrides are what keep integer hashing
/// endian-independent (the core::hash defaults go through native-endian
/// bytes); pin their exact outputs so mutations to `update` and
/// `large_update` cannot survive.
#[test]
fn write_integer_outputs_are_pinned() {
let build = RandomState::with_seeds(1, 2, 3, 4);
let finish = |f: &dyn Fn(&mut DeterministicHasher)| {
let mut hasher = build.build_hasher();
f(&mut hasher);
hasher.finish()
};

assert_eq!(finish(&|h| h.write_u8(0xa5)), 18_003_160_624_292_972_093);
assert_eq!(finish(&|h| h.write_u16(0xa5a5)), 13_293_305_771_500_139_834);
assert_eq!(
finish(&|h| h.write_u32(0xa5a5_a5a5)),
1_920_462_131_907_294_801
);
assert_eq!(
finish(&|h| h.write_u64(0xa5a5_a5a5_a5a5_a5a5)),
3_694_995_219_585_681_531
);
assert_eq!(
finish(&|h| h.write_u128(0xa5a5_a5a5_a5a5_a5a5_a5a5_a5a5_a5a5_a5a5)),
5_605_269_915_146_111_207
);
assert_eq!(
finish(&|h| h.write_usize(0x1234)),
3_213_796_315_339_447_371
);
}

#[test]
fn read_small_length_classes() {
Expand Down
Loading
Loading