Skip to content

fix: size the storage in the initializer list constructor - #74

Merged
oberbichler merged 1 commit into
mainfrom
fix/initializer-list-ctor
Jul 26, 2026
Merged

oberbichler merged 1 commit into
mainfrom
fix/initializer-list-ctor

Conversation

@oberbichler

Copy link
Copy Markdown
Owner

Problem

DDScalar(std::initializer_list<TScalar> data)
    : m_size(size_from_data_length(length(data))) {
  std::copy(data.begin(), data.end(), m_data.begin());
}

m_size is derived from the length of the data, but m_data is never sized — and the length is validated only against size_from_data_length, which accepts any triangular length, not the one the type actually requires. Three ways to break it, all confirmed under AddressSanitizer:

DDScalar<2, double, Dynamic>{1, 2, 3, 4, 5, 6}
// AddressSanitizer: SEGV on unknown address 0x000000000000
//   #7 DDScalar<2l, double, -1l>::DDScalar(std::initializer_list<double>) hyperjet.h:314
// m_data is an empty vector when std::copy writes into it

DDScalar<2, double, 3>{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
// AddressSanitizer: stack-buffer-overflow
// 15 doubles copied into a std::array<double, 10>

DDScalar<2, double, 3>{1, 2, 3}
// static(3) f=1.000000 size=3 h(2,2)=3.26066e-314
// silently constructed, 7 of 10 slots uninitialized, and size() reports 3
// while m_size is 1

The two static cases get past size_from_data_length because 15 and 3 are valid triangular lengths — for size 4 and size 1 respectively. They are simply not valid for size 3, and nothing compared them against TSize.

In a release build the dynamic case traps immediately (exit 133); under ASan it is a null write.

Fix

check_valid_size(m_size);

if constexpr (is_dynamic()) {
  m_data.assign(data.begin(), data.end());
} else {
  std::copy(data.begin(), data.end(), m_data.begin());
}

check_valid_size already exists and does exactly the right thing per variant: compare against TSize for static types, reject a negative size for dynamic ones. assign sizes the vector from the data.

Making the dynamic variant work rather than constraining the constructor away seems right: deriving m_size from the data length only ever made sense for the dynamic variant — for static types m_size is unused, since size() returns TSize — so supporting it was clearly the intent.

Tests

TEST_CASE("Initializer list construction"), written first. RED was the test binary dying with exit 133 (SIGTRAP) and no doctest output at all, because the dynamic construction on the first line trapped before anything could be reported.

Covered: the dynamic variant for order 1 and 2 (size, data length, and every value read back through f/g/h), the valid static length, both invalid-but-triangular static lengths, and a non-triangular length as a guard on the pre-existing check.

After: C++ 41/41 test cases, 415 assertions in both Debug and Release · Python 147 passed · clang-format --Werror and ruff format --check clean · all three cases re-verified under ASan — dynamic constructs correctly, both static mismatches throw std::runtime_error: Invalid size, no sanitizer reports.

The existing dd1/dd2/dd3 globals in test.cpp and benchmarks.cpp use exactly 10 elements for DDScalar<2, double, 3>, so they exercise the valid path and would have failed had the length check been wrong.

Notes

Not reachable from Python — the bindings construct from data via create(), which validated the length already.

Independent of #73 (still open); the two touch different parts of the header and merge in either order.

This is the third memory-safety bug in a row that an ASan/UBSan job would have caught before merge. That is item 7 on the review list and probably worth doing before the remaining fixes.

Still open from the review:

  • x *= x and x /= x produce wrong derivatives (self-aliasing) in both DDScalar and SScalar.
  • resize() silently scrambles the Hessian layout for order 2; pad_right() does it correctly.

The constructor derived m_size from the length of the data but never sized
m_data, and it validated that length only against size_from_data_length —
which accepts any triangular length, not the one the type requires. Three
ways to break it, all confirmed under ASan:

    DDScalar<2, double, Dynamic>{1, 2, 3, 4, 5, 6}
    // SEGV — m_data is an empty vector when std::copy writes into it

    DDScalar<2, double, 3>{1, ..., 15}
    // stack-buffer-overflow — 15 doubles into a std::array<double, 10>

    DDScalar<2, double, 3>{1, 2, 3}
    // silently constructed, 7 of 10 slots uninitialized, size() reports 3
    // while m_size is 1 — h(2, 2) returned 3.26e-314

The static cases pass size_from_data_length because 15 and 3 are valid
triangular lengths for size 4 and size 1 respectively; they are just not
valid for size 3. check_valid_size compares against TSize and catches both.

For the dynamic variant, assign() sizes the vector from the data. Deriving
m_size from the length only ever made sense for that variant — for static
types m_size is unused — so the intent was clearly to support it.

Not reachable from Python: the bindings construct from data via create(),
which validated the length already.
@oberbichler
oberbichler merged commit 809d6db into main Jul 26, 2026
16 checks passed
@oberbichler
oberbichler deleted the fix/initializer-list-ctor branch July 26, 2026 20:14
oberbichler added a commit that referenced this pull request Jul 26, 2026
Three memory-safety bugs in a row were found by running the tests under
AddressSanitizer by hand. This makes that part of CI.

Two options on the test target, both off by default:

  - HYPERJET_SANITIZE builds with -fsanitize=address,undefined and
    -fno-sanitize-recover=all, so undefined behaviour aborts instead of
    printing and passing.

  - HYPERJET_WERROR promotes warnings to errors. Warnings themselves
    (-Wall -Wextra -Wpedantic, /W4) are always on, so they are visible
    locally too, but only one CI job fails on them — warning sets differ
    between compilers, and an unrelated toolchain should not be able to
    block every build.

The new job runs both on clang, which also covers a third front-end next
to GCC 14 and MSVC. Eigen and doctest are now included as SYSTEM headers so
third-party warnings cannot fail our build.

Verified that the job is not decorative: with a reproducer for the
initializer list bug still open in #74, it fails as intended —

    test case CRASHED: SIGSEGV - Segmentation violation signal
    ==1413==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000
        #7 DDScalar<2l, double, -1l>::DDScalar(std::initializer_list<double>)
           hyperjet.h:314

Warnings that -Werror surfaced:

  - SScalar::hypot kept f3, a2, b2 and c2 from the DDScalar version, where
    they carry the second-order terms. SScalar is first order only, so they
    were dead.

  - DDScalar(const Data&, index) initialized m_data before m_size while the
    declaration order is the reverse (-Wreorder-ctor). Harmless today, since
    the vector constructor does not read m_size, but it is a warning inside
    a header shipped to users.

Neither of those warnings appeared before, because nothing instantiated the
functions: the tests never touched SScalar::hypot or a dynamic DDScalar. A
warnings job is only as good as the instantiation coverage, so this adds the
missing SScalar::hypot test case — which is what surfaced the dead
variables. The dynamic constructor gets covered by the tests in #73.

Also adds <algorithm> and <stdexcept>, which the header used through
transitive includes only. Included here because the new job compiles with a
front-end the project has not built with before, and missing includes are
exactly what breaks first under a new toolchain.

Workflow-level permissions: contents: read while in this file. The other
three workflows still need the same.
@oberbichler oberbichler mentioned this pull request Jul 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant