fix: OOB read in HSJ token_states/CanonOrder lookup - #73
Merged
Conversation
fitch_label_char()'s uppass loop formed `&co.kids[co.kidOff[node]]` before testing `nk`, the node's child count. CanonOrder stores children CSR-style, so `kidOff[n]` for a childless node is whatever `kids.size()` happened to be when the DFS popped it -- and for the LAST node popped that is the final size, every other node having already contributed its children by then. `co` arrives as a const reference, so this is `std::vector<int>::operator[](size()) const`: a dereference of one past the end. The downpass and the tie-break accumulation loops above both already `continue` on `nk == 0`; this loop did not. Reproduced against `-D_GLIBCXX_ASSERTIONS` (flag in PKG_CPPFLAGS, since ~/.R/Makevars.win zeroes PKG_CXXFLAGS; 34 hits in the build log). Pre-fix, four test files abort on entry to their first HSJ block with `Assertion '__n < this->size()' failed`; post-fix all four run clean: test-tree_length.R abort -> 77 passed test-ts-xform.R abort -> 138 passed test-ts-hsj.R abort -> 147 passed test-ts-resample-hierarchy.R abort -> 74 passed The reporter's second reproducer (test-ts-xform.R) is the same defect, not a second one: it aborts inside the third test, which is the file's first `inapplicable = "hsj"` search -- hence exactly five assertions first, from the two preceding pure-xform tests. `kid` is never dereferenced when `nk == 0`, so no value was read through the bad reference and no score moves: 900 HSJ and x-transformation lengths over random matrices (4-16 tips, alpha 0/0.5/1), the issue's own data, and three seeded end-to-end searches are bit-identical either side of the fix, Inf entries included. Fixes #51 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue #51 asked for this: the memory note it was filed against points at the ASan workflow as the route to a container-OOB, and that workflow was red on trunk for the very defect the previous commit fixes. A local -D_GLIBCXX_ASSERTIONS build reproduces the same class in seconds on Windows and can be aimed at one test file, so it belongs in the subsystem's own memory file, next to the flat-vector layouts that make this the recurring failure mode here. Also records the two ways to misread its output: the abort names the container type, never the call site; and `lib.loc` must be an absolute Windows path, or test_file()'s chdir breaks the lazy-load DB and fakes several regressions. Qualifies the in-source issue reference per AGENTS.md, since src/ fast-forwards to the public upstream, where a bare `#51` resolves to an unrelated issue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Independent review of ca5c1c0 disputed "the last node DFS popped". It is not one node but a run of them: every childless node reached after the final push_back carries the end offset. Measured on an R mirror of build_canon_order() over 900 random trees, 2-24 tips: trees with NO kidOff==size node: 0 trees with >1 such node : 843 kidOff/kidNum CSR consistency : OK The first line matters most -- the pre-fix code formed a reference to co.kids.end() on EVERY HSJ scoring call, not on some unlucky shape. The third rules out the alternative reading that the guard papers over a corrupt CSR: kids[off + 1 .. off + num] is exactly each node's canonical children, so kidOff/kidNum are sound and only the missing nk == 0 test was wrong. Comment and NEWS reworded accordingly; the script lands under dev/red-team/reviews/ as the standing evidence. The same review found a SEPARATE unguarded bound -- tip_labels' row count is validated at neither Rcpp bridge, giving an identical `_Tp = int` const-operator[] abort from a hand-crafted TreeSearch::: call. Confirmed against a build already carrying this fix, so it is not the same defect, and it is unreachable from the public API. Filed as #58 rather than widened into this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…opped Review read "which is what made the AddressSanitizer workflow unusable" as crediting ASan's own instrumentation, and objected -- correctly -- that ASan watches accesses, not address arithmetic, and that co.kids.reserve(n_node) leaves the offending address inside the live allocation anyway. The claim was about the right defect but named the wrong instrument. What aborted the gcc-ASAN job is visible in #51's own evidence: `stl_vector.h:1282 ... Assertion '__n < this->size()' failed`, a libstdc++ precondition check, not a sanitizer report. The r-hub gcc-asan container compiles with the hardened library; the flag is not in ASan.yml, which is why grepping .github/workflows/ for it finds nothing and misleads. Reworded to say what actually fires and why that workflow could not get past this package. Also distinct from the EARLIER ASan unusability that PRs ms609#262/ms609#275 fixed -- that was the runner configuration; this is a defect the now-working workflow immediately hit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Fixes #51
Root cause — one defect, one site
fitch_label_char()insrc/ts_hsj.cpplabels each secondary character over atraversal rooted canonically at tip 0.
build_canon_order()stores thattraversal's children CSR-style:
co.kidsflattened, withco.kidOff[n]/co.kidNum[n]delimiting noden's slice.kidOff[n]is written when the DFS popsn:For a childless node that is simply "wherever
kidshad got to". Every nodeexcept the canonical root becomes some node's child exactly once, so
kidsends at
n_node - 1entries — and the last node the DFS pops necessarilyhas no unseen neighbours (or it would have pushed them), so its
kidOffequals the final
kids.size().The uppass loop then did:
coarrives asconst CanonOrder&, so that isstd::vector<int>::operator[](size()) const— dereferencing one past the end,which is exactly the reported signature (
_Tp = int, const reference).The downpass (
:189) and the tie-break accumulation (:256) both alreadycontinueonnk == 0; this third loop did not.The fix adds the same guard.
kidis only ever read by the immediatelyfollowing
for (int k = 0; k < nk; ++k), whose body cannot execute whennk == 0, so skipping the iteration is observationally identical.The two reproducers are the same defect, not two
The issue's second reproducer (
test-ts-xform.R, aborting after exactly fiveassertions) was reported as possibly not involving
inapplicable = "hsj".It does. Counting the file in order:
Xform prefers single gain + losses over multiple gainsXform penalizes secondary variation on present branchesHSJ and xform agree on optimal tree for simple gain scenarioThe third test's first statement is
MaximizeParsimony(ds, hierarchy = h, inapplicable = "hsj", ...). Fiveassertions then an abort is precisely that. Both reproducers enter
score_hierarchy_block()→fitch_label_char()and die at:302.I separately ruled out the other lead named in the issue:
ts_hsj_score()validates
tip_labelsagainstcontrast.nrow()/contrast.ncol()beforemake_dataset()runs, andbuild_dataset()assignsds.token_states = token_states(sizedn_tokens = contrast.nrow()) andds.n_levels = n_statesverbatim, explicitly un-remapped for HSJ(
src/ts_data.cpp:56-59). The pre-construction bounds are therefore exactlythe post-construction ones; that path is sound. No OOB was found on the
pure-XFORM/Sankoff path either.
Before / after under
-D_GLIBCXX_ASSERTIONSBoth libraries built from a tarball into a private library per AGENTS.md, with
the flag in
PKG_CPPFLAGS(~/.R/Makevars.winzeroesPKG_CXXFLAGS).grep -c _GLIBCXX_ASSERTIONSon each build log: 34, both.BEFORE (trunk,
2c59965e6)AFTER (this branch)
Matched sweep of every hierarchy-touching test file, same two libraries:
test-tree_length.Rtest-ts-xform.Rtest-ts-hsj.Rtest-ts-resample-hierarchy.Rtest-ts-hsj-xform-guards.Rtest-recode-hierarchy.Rtest-ts-t330-collapse-hsj-xform.Rtest-CharacterHierarchy.RThe five
test-CharacterHierarchy.Rerrors are identical either side and arean artefact of my invocation, not of the package: that file calls internals
(
.BuildTipLabels,.HierarchyToBlocks,.NonHierarchyWeights) unqualified,which
library()+test_file()cannot see butR CMD check's namespaceenvironment can.
No scored value moves
The bug is a bad reference, never a bad read —
kidis unreachable whennk == 0— so the fix should be score-neutral, and is. 900 lengths frommatched plain (non-assertions) builds of trunk and this branch:
rooted trees each,
hsj_alpha∈ {0, 0.5, 1}, plus the XFORM length of thesame tree;
HSJ and XFORM;
MaximizeParsimony()searches (HSJ and XFORM) on the6-taxon matrix from
test-ts-xform.R.identical(pre, post)on the whole result frame isTRUE: 900/900 equal,max absolute difference 0 over the 897 finite scores (range 1–25), and the 3
Infentries match asInfon both sides.Regression test
None added, deliberately. The defect is a one-past-the-end reference that is
never dereferenced, so it has no observable effect in an ordinary build — a
normal-build test would pass identically before and after the fix and would be
tautological. The existing HSJ suites are the regression test; what was
missing was a build that checks bounds.
ASan.ymlis that gate, and per #51 itis red on trunk for this defect alone; it is dispatched on this branch and its
result is the real proof. No permanent
-D_GLIBCXX_ASSERTIONSCI job is addedhere — that is an infra decision outside this issue.
Checks
spelling::spell_check_package()andtests/spelling.R— clean; noinst/WORDLISTaddition needed.check_init.R— 50 init.c entries, 48 RcppExports entries, all shared argcounts match (unchanged; the 2 manual entries are pre-existing).
.claude/tools/compile-attrs.Rnot run anddevtools::check_man()not run: neither trigger fires.
fitch_label_char()is a file-staticC++ function with no Rcpp export and an unchanged signature; no roxygen
block, R signature, or documentation prose changed.
src/ts_rcpp.cppandsrc/TreeSearch-init.cuntouched, so the append-onlyrule is not engaged. No
src/Makevars.wincreated or left behind.vignettes/search-algorithm.Rmdneeds noupdate.
Diff is 7 lines of
src/ts_hsj.cpp(1 statement + 6 comment) and a NEWS entry.CI
agent-check— 30973482216 — success (ubuntu-arm64, windows), run on this branch's final commit.ASan— 30971403379 — success, all three legs (tests/examples/vignettes). This is the workflow HSJ scoring reads past the end of astd::vector<int>, caught by-D_GLIBCXX_ASSERTIONS#51's third comment reported red on trunk for this exact defect (stl_vector.h:1282, identical_Tp = intconst-operator[]signature) — it now passes.Reviewed
Three independent
external-reviewerpasses (load-bearing / scoring-semantics / conventions), all recommending ship. Two follow-ups were filed rather than folded in, since both are out of this issue's scope:tip_labelsrow-count bound at the same two Rcpp bridges, same assertion signature, confirmed independently against a build already carrying this fix. Not reachable from the public API.-D_GLIBCXX_ASSERTIONS, so this defect class (visible to neither ordinary builds, Valgrind, nor ASan's own instrumentation — only to libstdc++'s own precondition checks) has no automated gate short of thegcc-ASANcontainer incidentally carrying the hardened library.Review also corrected the PR's own mechanism description (a run of childless nodes carries the stale end-offset on every HSJ call, not "the last node" alone — see
dev/red-team/reviews/feature-hsj-oob-read/repro-02-canon-order-invariant.R) and its ASan phrasing (the job dies on the libstdc++ assertion, not on ASan's own instrumentation, which does not watch address arithmetic).