Summary
YAML::Node's iterator declares iterator_category = std::forward_iterator_tag but does not satisfy the semantic multi-pass guarantee required by std::forward_iterator (iterator.concept.forward). This causes silent incorrect behaviour when the iterator is used with C++20 <ranges> machinery, which trusts the declared category.
What the standard requires
std::forward_iterator requires the multi-pass guarantee: if a == b, then *a and *b must refer to the same object. This is a semantic requirement; there is no compile-time concept check that enforces it.
std::views::transform and other range adapters propagate the iterator category from the underlying range. If a range claims to be a forward_range, the resulting view also claims to be one. Code downstream that relies on multi-pass behaviour then has formally undefined behaviour.
Reproduction
#include <yaml-cpp/yaml.h>
#include <gtest/gtest.h>
#include <memory>
#include <forward_list>
// Syntactic check passes -- concepts only see iterator_category, not semantics.
static_assert(std::ranges::forward_range<YAML::Node>);
// FAILS at runtime: two copies of the same iterator dereference to distinct objects.
// std::forward_iterator requires *a and *b to refer to the same object when a == b.
// https://en.cppreference.com/w/cpp/iterator/forward_iterator
TEST(YamlCppMapIterator, DereferenceOperator_WithEqualIteratorCopies_ReturnsSameObject)
{
YAML::Node node = YAML::Load("{key: value}");
auto it_a = node.begin();
auto it_b = node.begin();
ASSERT_EQ(it_a, it_b);
const auto& ref_a = *it_a;
const auto& ref_b = *it_b;
EXPECT_EQ(std::addressof(ref_a), std::addressof(ref_b)); // FAILS
}
// Control: std::forward_list::iterator is a true forward iterator.
// operator*() returns int& -- a stable reference to the stored node.
// https://en.cppreference.com/w/cpp/container/forward_list
TEST(ForwardListIterator, DereferenceOperator_WithEqualIteratorCopies_ReturnsSameObject)
{
std::forward_list<int> list = {42};
auto it_a = list.begin();
auto it_b = list.begin();
ASSERT_EQ(it_a, it_b);
const auto& ref_a = *it_a;
const auto& ref_b = *it_b;
EXPECT_EQ(std::addressof(ref_a), std::addressof(ref_b)); // PASSES
}
Observed output from the yaml-cpp test:
Expected equality of these values:
std::addressof(ref_a) Which is: 000000D0A4B9EC50
std::addressof(ref_b) Which is: 000000D0A4B9ED50
The addresses differ -- each iterator copy holds its own iterator_value member, and operator*() returns it by value, materialising a new temporary per call.
Root cause
iterator_base<V>::operator*() (iterator.h) returns value_type by value:
value_type operator*() const
{
// constructs and returns a new iterator_value every call
}
YAML::Node is a shallow-copying reference handle, not a value type. Each dereference constructs a new iterator_value{Node first; Node second;} wrapping the same underlying ref-counted data but at a new address. Two copies of the same iterator therefore dereference to two distinct C++ objects, violating [iterator.concept.forward]/6.
This is also related to #1326 (value_type being const-qualified), which is a separate but symptomatically similar violation of std::indirectly_readable.
Why this is a bug and not a design choice
Prior to C++20, iterator_category tags were advisory. In C++20, std::forward_iterator is a formal concept with normative semantic requirements. Code using std::views::transform or other range adapters on a YAML::Node will produce a forward_range view based on a false premise. Any algorithm that exploits multi-pass behaviour on that view has formally undefined behaviour, even if it happens to work in practice in a trivial single-pass use.
Proposed fixes
Option A (recommended): Change iterator_category to input_iterator_tag
The iterator is semantically an input iterator. Declaring it as such is honest and correct. Single-pass range-for loops and std::views::transform all work on input ranges. This is a breaking change for callers that pass the iterator to algorithms requiring ForwardIterator, but those callers already have latent UB.
Option B: Return a stable reference from operator*()
Store the current iterator_value inside the iterator as a member and return iterator_value&. However, since two equal iterators are independent copies each holding their own member, they still dereference to different objects. This does not fix the multi-pass violation and adds overhead.
Option C: Shared materialised state
All copies of the same logical iterator position share ownership of a single heap-allocated iterator_value. Satisfies the multi-pass guarantee mechanically, but adds allocation overhead and complicates copy semantics.
Option D (consuming-code workaround): Wrapper that re-declares input_iterator_tag
Users can wrap YAML::Node's iterator in a thin adapter that overrides iterator_category to input_iterator_tag. This is a local fix that does not require yaml-cpp changes, at the cost of boilerplate per use site.
Disclaimer: this bug was found by an AI coding tool (GitHub Copilot), with the assistance and review of a real human. This report was posted at the direct request of that human. The account posting this report is owned and operated by a real person.
Summary
YAML::Node's iterator declaresiterator_category = std::forward_iterator_tagbut does not satisfy the semantic multi-pass guarantee required bystd::forward_iterator(iterator.concept.forward). This causes silent incorrect behaviour when the iterator is used with C++20<ranges>machinery, which trusts the declared category.What the standard requires
std::forward_iteratorrequires the multi-pass guarantee: ifa == b, then*aand*bmust refer to the same object. This is a semantic requirement; there is no compile-time concept check that enforces it.std::views::transformand other range adapters propagate the iterator category from the underlying range. If a range claims to be aforward_range, the resulting view also claims to be one. Code downstream that relies on multi-pass behaviour then has formally undefined behaviour.Reproduction
Observed output from the yaml-cpp test:
The addresses differ -- each iterator copy holds its own
iterator_valuemember, andoperator*()returns it by value, materialising a new temporary per call.Root cause
iterator_base<V>::operator*()(iterator.h) returnsvalue_typeby value:YAML::Nodeis a shallow-copying reference handle, not a value type. Each dereference constructs a newiterator_value{Node first; Node second;}wrapping the same underlying ref-counted data but at a new address. Two copies of the same iterator therefore dereference to two distinct C++ objects, violating [iterator.concept.forward]/6.This is also related to #1326 (
value_typebeingconst-qualified), which is a separate but symptomatically similar violation ofstd::indirectly_readable.Why this is a bug and not a design choice
Prior to C++20,
iterator_categorytags were advisory. In C++20,std::forward_iteratoris a formal concept with normative semantic requirements. Code usingstd::views::transformor other range adapters on aYAML::Nodewill produce aforward_rangeview based on a false premise. Any algorithm that exploits multi-pass behaviour on that view has formally undefined behaviour, even if it happens to work in practice in a trivial single-pass use.Proposed fixes
Option A (recommended): Change
iterator_categorytoinput_iterator_tagThe iterator is semantically an input iterator. Declaring it as such is honest and correct. Single-pass range-for loops and
std::views::transformall work on input ranges. This is a breaking change for callers that pass the iterator to algorithms requiringForwardIterator, but those callers already have latent UB.Option B: Return a stable reference from
operator*()Store the current
iterator_valueinside the iterator as a member and returniterator_value&. However, since two equal iterators are independent copies each holding their own member, they still dereference to different objects. This does not fix the multi-pass violation and adds overhead.Option C: Shared materialised state
All copies of the same logical iterator position share ownership of a single heap-allocated
iterator_value. Satisfies the multi-pass guarantee mechanically, but adds allocation overhead and complicates copy semantics.Option D (consuming-code workaround): Wrapper that re-declares
input_iterator_tagUsers can wrap
YAML::Node's iterator in a thin adapter that overridesiterator_categorytoinput_iterator_tag. This is a local fix that does not require yaml-cpp changes, at the cost of boilerplate per use site.Disclaimer: this bug was found by an AI coding tool (GitHub Copilot), with the assistance and review of a real human. This report was posted at the direct request of that human. The account posting this report is owned and operated by a real person.