Fix UB in index_map::insert - #673
Conversation
59f0cd6 to
9eb3ac4
Compare
|
Anybody got ideas on how to prevent the stack overflow in the test? |
b987ff2 to
c43ce68
Compare
|
I got the test working by allocating/initializing manually with some unsafe. |
zeenix
left a comment
There was a problem hiding this comment.
I won't have time for reviewing the code itself until at least tomorrow but I trust you with that. Nitpicks OTOH are easy and quick. :)
zeenix
left a comment
There was a problem hiding this comment.
I was not able to find any issue with this PR but I wanted to be sure so I asked an LLM to see if it can find problems. I was hoping it doesn't or comes up with bogus issues but it came up with 2 big issues here that AFAICT are geniune and serious. I know you hate LLMs but these seems like serious issues to ignore so I have to share:
1. find passes the wrong length — re-introduces the same UB (blocker)
src/index_map.rs:240:
let pos = self.indices[probe].as_valid::<N>(self.indices.len())?;
// ^^^^^^^^^^^^^^^^^^as_valid's own doc says "The argument is the length of the entries buffer", but indices.len() is always N. So buffer_max collapses to just N == MAX_SIZE, and for N == 0x10000 every empty slot is decoded as a valid Pos { hash: 0xFFFF, index: 0xFFFF }, regardless of how many entries the map actually holds. insert, reinsert_all etc. all correctly pass self.entries.len(); find is the only one that doesn't.
In debug this trips the assert three lines below:
panicked at src/index_map.rs:247:13: assertion failed: i < self.entries.len()
In release that line is unsafe { self.entries.get_unchecked(0xFFFF) } on a map holding one entry — a read of uninitialised memory. With a key type whose zeroed bytes compare equal, get returns an entry that was never inserted:
get(&KC(0)) on a map containing only KC(1) -> Some(0)
This is genuine UB, not merely a wrong answer: the same test returned None until I added an unrelated println! nearby, after which it returned Some(0). get_mut/index_mut would hand out &mut V into that uninit memory, and remove would call swap_remove_unchecked(0xFFFF) on a 1-element Vec.
(A completely empty map is safe only by accident — IndexMap::find has an if self.is_empty() { return None } guard, so you need at least one entry to reach this.)
Fix:
let pos = self.indices[probe].as_valid::<N>(self.entries.len())?;2. continue inside probe_loop! causes an infinite loop (blocker)
src/index_map.rs:350, in remove_found:
probe_loop!(probe < self.indices.len(), {
if probe == old_probe {
continue;
}
...
});probe_loop! expands to loop { if probe < len { $body probe += 1; } else { probe = 0; } }, so a continue in the body skips the probe += 1 and re-enters with the same probe — it never terminates.
This is reachable with tiny maps; no N == 0x10000 required. Three keys colliding into bucket 0 of an N = 4 map, then removing the first, hangs forever (I killed it at 60s):
let mut map: IndexMap<K16, u16, DHB, 4> = IndexMap::default();
map.insert(K16(0), 0).unwrap();
map.insert(K16(4), 4).unwrap();
map.insert(K16(8), 8).unwrap();
map.remove(&K16(0)); // never returnsThe insert_overflow test doesn't hit it because that particular removal breaks out on the very first probe.
The guard itself is needed (in the full-map case indices[old_probe] was just set to Pos::none() and would decode as a false "found it"), so just restructure rather than continue:
if probe != old_probe {
if let Some(pos) = self.indices[probe].as_valid_mut::<N>(old_len) {
...
}
}I applied both fixes locally: the three repro tests pass, insert_overflow still passes, and the full suite with all features is green.
|
Thanks Looks like you didn't send the full context? What are the I was able to reproduce both and add tests for them, but your comment mention 3 repro tests, am I missing one? Rather than removing the |
Since you asked for it, this time I send you the full reponse from the LLM: Sorry — those snippets referenced helper types I never actually included. Full context below. Also, one correction to my earlier comment while I'm here: I described the bad read as hitting "uninitialised memory". That's right for a map built the normal way, but not for my repro, which builds the map with The missing helper typesCopies of the ones inside your /// A key whose hash is `self.0`, but whose identity also includes `self.1`,
/// so that two distinct keys can share one hash.
#[derive(PartialEq, Eq, Debug)]
struct K16(u16, u16);
impl Hash for K16 {
fn hash<H: Hasher>(&self, state: &mut H) {
state.write_u16(self.0);
}
}
/// A key whose hash is *always* `0xFFFF`.
#[derive(PartialEq, Eq, Debug)]
struct KC(u16);
impl Hash for KC {
fn hash<H: Hasher>(&self, state: &mut H) {
state.write_u16(0xFFFF);
}
}
#[derive(Default)]
struct DummyHasher(u16);
impl Hasher for DummyHasher {
fn finish(&self) -> u64 {
self.0 as _
}
fn write(&mut self, _bytes: &[u8]) {}
fn write_u16(&mut self, i: u16) {
self.0 = i;
}
}
#[derive(Default)]
struct DummyHasherBuilder;
impl BuildHasher for DummyHasherBuilder {
type Hasher = DummyHasher;
fn build_hasher(&self) -> DummyHasher {
DummyHasher(0)
}
}The third testYou're not missing a third bug — you're missing a second test for the /// `find` used to decode an empty slot as a valid `Pos { hash: 0xFFFF, index: 0xFFFF }`.
/// Equivalent to `indices_valid`: trips the `debug_assert!` in `find`.
#[test]
#[cfg_attr(miri, ignore)] // too slow
fn find_decoded_empty_slot() {
let map: Box<mem::MaybeUninit<IndexMap<K16, u16, DummyHasherBuilder, 0x10000>>> =
Box::new_zeroed();
let mut map = unsafe { map.assume_init() };
map.insert(K16(0xFFFF, 0), 1).unwrap();
// Distinct key, same hash: probes indices[0xFFFF], then wraps to indices[0].
// That slot is `Pos::none()`, but `find` passed `indices.len()` (always `N`) where
// `as_valid` wanted `entries.len()`, so the `buffer_max` short-circuit fired on any
// `N == 0x10000` map, full or not, and the slot decoded as valid.
assert_eq!(map.get(&K16(0xFFFF, 1)), None);
}
/// Same root cause, but the bogus entry compares *equal*, so `find` returned a hit and
/// `get` handed back a value from a slot past `entries.len()`. Unlike the test above,
/// this one also failed in release, where `debug_assert!` is compiled out.
#[test]
#[cfg_attr(miri, ignore)] // too slow
fn find_returned_stale_entry() {
let map: Box<mem::MaybeUninit<IndexMap<KC, u32, DummyHasherBuilder, 0x10000>>> =
Box::new_zeroed();
let mut map = unsafe { map.assume_init() };
map.insert(KC(1), 111).unwrap();
// KC(0) was never inserted. The slot at index 0xFFFF is past `entries.len()`; here it
// is all-zero (the map was zero-initialised above), which decodes as `KC(0)`, so the
// key comparison succeeds and `get` returns a value that was never stored.
assert_eq!(map.get(&KC(0)), None);
}
/// `continue` inside `probe_loop!` skipped the `probe += 1`.
/// Equivalent to your `remove_found_loop`.
#[test]
fn remove_found_hang() {
let mut map: IndexMap<K16, u16, DummyHasherBuilder, 4> = IndexMap::default();
map.insert(K16(0, 0), 0).unwrap();
map.insert(K16(4, 0), 4).unwrap();
map.insert(K16(8, 0), 8).unwrap();
assert_eq!(map.remove(&K16(0, 0)), Some(0)); // used to never return
}Why the second one earns its place: Reverting only the Same revert,
On the
|
Previously, index_map was broken in two ways:
Firstly, indices are expected to only be 16 bits but the N parameter
could be any usize, including > u16::MAX + 1
This is fixed with a static assertion.
Secondly, the indexmap assumed that an index of 0xFFFF and an hash of
0xFFFF would never happen, but this could be wrong, and lead to the
safety requirement of NonZeroU32::new_unchecked to be violated.
This PR fixes this by removing the option and the automatic niche value
optimization of `Pos`, and re-implementing it manually.
To make sure that there is no confusion in the case where the index
is 0xFFFF and so is the hash, and additional bit of information is used
to determine whether the 0xFFFF hash and indices represent a None value
or whether it represent Some({hash_value: 0xFF, index: 0xFF})
If the map is full of 0x10000 items, then we know that **all** pos in the
indices are valid buffer are valid.
Otherwise, we know that index = 0xFFFF is simply not possible, therefore
we know that a Pos with such an index is `None`.
see rust-embedded#672 for the original
report
|
Thanks, I added the third test which does indeed cover a distinct code path. Regarding AI use, I don't complain on how you find bugs as longs as they're legit (as you can see from the bug reports coming from LLMs I fixed), I still find it somewhat rude to paste multiple paragraphs of LLM output for me to sort through, having to find what is useful in them and separate that from the rest. I'd rather have had 2 comments even if they're pretty raw like:
and
That would have conveyed pretty much as much useful info as the first message (and you could still have pointed out they were found by LLM if you wanted) |
That's quite subjective, sorry. I agree with you that as long as the issues are geniun, then there shouldn't be an issue using LLMs. I'm happy to bear the costs involved here. |
zeenix
left a comment
There was a problem hiding this comment.
I asked LLM to be super concise this time (I'll have to remember to do that each time):
indices_valid2 has the keys the wrong way round, so it doesn't cover the extra path yet.
The stale slot at index 0xFFFF is zeroed, so it decodes as ConstantHash(0) — the query has to be ConstantHash(0) for the bogus key comparison to succeed. As written (insert 0, look up 1) the comparison fails, so it only trips the debug_assert!, same as indices_valid. With the as_valid fix reverted it passes in release.
Swapping them makes it fail in both profiles:
map.insert(ConstantHash(1), 1).unwrap();
// Distinct key, same hash
assert!(map.get(&ConstantHash(0)).is_none());|
LLM caught something still: The One thing on the new Clippy only learned to accept that placement somewhere after 1.94. CI is on 1.97 so it's green, but on 1.87 (the crate's MSRV) and on 1.94 it's Moving the comment above the Also, please don't forget to squash the fixup commits after so we don't end up with them in master. |
|
You consider clippy to be covered by MSRV? I consider the MSRV to only apply to users downstream, not to what is needed to work on heapless itself. |
As_valid expect the number of entries to be passed, not the length of the indices array, which is always `N`
probe_loop! relied on code running after `$body`, which meant using `continue` in `$body` could break it.
Document all conversions from `Pos` to `ValidPos`. Document all usages of `unsafe` Add a test with a map of maximum size filled to test the `valid_pos` conversions
It's just a bit inconsistent but if it doesn't/can't cause any issues, sure I don't mind. |
Previously, index_map was broken in two ways:
Firstly, indices are expected to only be 16 bits but the N parameter could be any usize, including > u16::MAX + 1. This is fixed with a static assertion.
Secondly, the indexmap assumed that an index of 0xFFFF and an hash of 0xFFFF would never happen, but this could be wrong, and lead to the safety requirement of NonZeroU32::new_unchecked to be violated.
This PR fixes this by removing the option and the automatic niche value optimization of
Pos, and re-implementing it manually.To make sure that there is no confusion in the case where the index is 0xFFFF and so is the hash, and additional bit of information is used to determine whether the 0xFFFF hash and indices represent a None value or whether it represent Some({hash_value: 0xFFFF, index: 0xFFFF})
If the map is full of 0x10000 items, then we know that all pos in the indices are valid buffer are valid. Otherwise, we know that index = 0xFFFF is simply not possible, therefore we know that a Pos with such an index is
None.See #672 for the original report.