fix: index underflow panic in TelepenReader::setCounters - #96
Conversation
…th no recorded transitions When a row never records a counter (e.g. an entirely-black row, where the initial scan for the first white pixel consumes the whole row), counterLength is still 0 when the trailing noise-merge runs. The merge then evaluates counters[counterLength - 1], which underflows to usize::MAX and panics with 'index out of bounds' (or 'attempt to subtract with overflow' in debug builds). The in-loop noise-merge already guards this case with '|| self.counterLength == 0'; this applies the same guard to the identical trailing block, so the count is appended instead of merged into a nonexistent previous counter. Decoding then fails gracefully with NotFoundException. Adds a regression test with an all-black 2048-wide row, which panics at telepen_reader.rs:311 before this fix.
|
Some field data that may be useful context, from running this patch's format restriction across ~20,000 real product photographs. On the panic's frequency: the unrestricted multi-format scan hit this underflow on 170 of 19,804 images (0.86%). Not an exotic edge case — ordinary retail packaging shots, where the Telepen reader was speculatively tried on rows that had no recorded transitions. On the Telepen reader's precision, separately from the panic: across that same estate the reader produced exactly one successful decode, and its payload was Control characters. The image's actual barcode was the EAN-13 That is not an argument against the reader — Telepen is a real symbology and someone scanning genuine Telepen labels needs it. But it does suggest the reader is currently prone to firing on non-Telepen input, and that the underflow this PR fixes is the crash-shaped tip of that: Happy to open a separate issue for the precision question if that is useful, or to add a stricter start/stop-pattern guard in this PR if you would prefer them together. Left as-is for now since a minimal, reviewable fix for the panic seemed the better first contribution. |
|
@jmortlock I'm the maintainer of the
|
|
Hi @axxel I'm not in a position to share the entire set as its licensed data, I did run the failing image through your library and it worked fine. I did find a publicly available image which is the same image just lower resolution (I'm using an original 5025x5025 JPEG) and interestingly it works fine on both libraries. https://assets.woolworths.com.au/images/1005/98361_2.jpg?impolicy=wowsmkqiema |
Thanks for the feedback. If you have the time and assuming your image set is available in a folder of standard image files, could you do me the favor and run it through the above linked benchmark and share the results?
That file seems too low res for both rxing and zxing-cpp. Are you sure you used that one for your test? |
|
@axxel You are right, and my earlier note was wrong. Apologies. I re-checked that public file just now. It is 1200x1200, and it decodes in neither library: (zxing-cpp built with The comparison table I posted was produced against the 5025x5025 original, not that URL. I went looking for a public stand-in for an image I cannot share, found one of visibly the same product shot, and reported it as equivalent without re-running the test against it. That was sloppy of me. 1200x1200 also appears to be the maximum that host will serve publicly — the other So the accurate statement is narrower than what I wrote: at 5025x5025, rxing 0.9.2 returns a phantom On the benchmark: I would like to run it, and there is a constraint I should be upfront about rather than quietly not replying. The set is ~19,800 licensed supplier photographs. I cannot share the images, and they are not sitting in a local folder — they live behind a licensor CDN, so a full pass means ~20k third-party fetches, which is not my call alone to spend. What I can realistically offer, if useful to you:
I will check whether the re-fetch is acceptable on our side and come back to you either way. If the aggregate output would still be worth having under those terms, say which of the two is more useful and I will aim for it. |
|
@jmortlock Thanks for the detailed explanation. I simply assumed you had local access to those mentioned 19k images. I'd be happy to get some aggregate results on any subset you might still have or easily get access to. |
|
Sorry it took me so long to look at this! I am probably the least familiar with how the Telepen reader works, as it was contributed by someone rather than me porting / building it. Regarding your question, I think having a stricter start/stop in this PR would be a good bundle if you don't mind adding that. What you have in it as of today looks great and pending passing integration tests I'd call it approved. |
Follow-up in this PR at the maintainer's suggestion, tightening the structural guards the underflow fix exposed. Three defects, each of which lets arbitrary input through the gate that is supposed to establish "this is a Telepen symbol": 1. `findStartPattern` never checked the wide element. The start pattern is ten narrow elements followed by one wide, but the wide test sat in an `else if j == 10` nested inside `if j < 10`, which is unreachable. Only the ten narrow elements were ever tested, so any run of ten narrow elements matched. 2. The narrow/wide threshold was `minBar + maxBar / 2.0`, which is the midpoint only when `minBar` is zero. With a narrow width of 2 and a wide of 6 it yields 5.0 instead of 4.0, admitting elements of width 5 as narrow. 3. A window of uniform width passed vacuously: with `minBar == maxBar` every element compares equal to the threshold and satisfies both the narrow and the wide test. A flat run of elements is the signature of noise rather than a symbol, and it matched the start pattern. The stop side had no structural check at all. `findEndPattern` locates the end of the symbol by looking for a quiet zone -- an element half again wider than anything nearby -- which any sufficiently isolated dark run satisfies, and accepts whatever precedes it. `checkStopPattern` now requires the symbol to end in the run of narrow elements that Telepen's stop character ends with. The wide elements preceding that run are deliberately not checked: how many survive binarisation varies with the trailing quiet zone, and requiring a full mirror of the start pattern rejects real symbols in `test_resources/blackbox/telepen-1`. The trailing-narrow count was taken from the decoded structure of that corpus rather than assumed. Elements are classified against the midpoint of the region being decoded, the same rule `decode_row` applies a few lines later, so none of this rejects anything the subsequent narrow/wide categorisation would have accepted. Tests: four unit tests covering the dead wide check, the skewed midpoint, the degenerate uniform window, and the missing stop check, plus `genuine_start_pattern_is_found` as a positive control. All four fail on main and pass here; the control passes on both, so it is not vacuous. `telepen_blackbox_1_test_case` (both alpha and numeric, at 0 and 180 degrees) still passes, as does the full `--features image,oned,decoders,image_formats` suite -- 659 lib tests and every blackbox target. `cargo clippy` reports the same 18 pre-existing warnings as main, and `cargo fmt --all` is clean.
|
Thanks @hschimke, and no problem at all. Stricter start/stop added in 9999bba. It turned out to be more than tightening a threshold. There are three separate defects on the start side, each of which independently defeats the check:
The stop side had no structural check at all. One deliberate limitation worth flagging for review, since it is a judgement call rather than a fact. I first implemented the stop as the exact mirror of the start (one wide, ten narrow). That rejected real symbols in Everything is classified against the midpoint of the region being decoded — the same rule On evidence. Each of the four new tests fails on
|
hschimke
left a comment
There was a problem hiding this comment.
The let median = minBar + maxBar / 2.0; is sending me, I can't believe I didn't catch that!
Problem
TelepenReader::setCounterspanics with:(at
telepen_reader.rs:311in 0.9.2,attempt to subtract with overflowin debug builds).When a row never records a counter — e.g. an entirely-black row, where the initial "move to first white pixel" scan consumes the whole row —
counterLengthis still 0 when the trailing noise-merge block runs. That block evaluatesself.counters[self.counterLength - 1], and0usize - 1underflows tousize::MAX.We hit this in production scanning ~20k real-world product photos through
MultiUseMultiFormatReader: 170 images reliably panicked the decode thread here.Fix
The in-loop noise-merge already guards this exact case with
|| self.counterLength == 0. This PR applies the same guard to the identical trailing block after the loop, so the final count is appended instead of merged into a nonexistent previous counter. Decoding then fails gracefully withNotFoundException.Test
Adds a minimal regression test (
all_black_row_returns_not_found_instead_of_panicking): a 2048-wide all-blackBitArray(wide enough thatminToleratedWidth >= 2, putting the trailingcountof 1 into the noise branch). It panics before the fix and returnsNotFoundExceptionafter.cargo test --lib oned::telepenandcargo test --test telepen_blackbox_1_test_case --features image,oned,decoders,image_formatsboth pass.