Skip to content

Commit 1b1232b

Browse files
authored
fix(avro): key projection cache on Avro node to avoid OOB read (#1022)
The avro-to-projection cache was keyed on projections.data() only. All empty-struct projections share the same (null) data(), so a second empty struct with more Avro fields reused a cached vector sized for the first and read out of bounds. Include the Avro node in the key and reject out-of-range projected field indexes.
1 parent 5107eda commit 1b1232b

4 files changed

Lines changed: 128 additions & 9 deletions

File tree

‎src/iceberg/avro/avro_direct_decoder.cc‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,26 +160,32 @@ Status DecodeStructToBuilder(const ::avro::NodePtr& avro_node, ::avro::Decoder&
160160

161161
// Build a map from Avro field index to projection index (cached per struct schema)
162162
// -1 means the field should be skipped
163-
const FieldProjection* cache_key = projections.data();
163+
// Empty projections all share one data() pointer, so the Avro node is part of the key.
164+
const DecodeContext::ProjectionCacheKey cache_key{projections.data(), avro_node.get()};
164165
auto cache_it = ctx.avro_to_projection_cache.find(cache_key);
165166
std::vector<int>* avro_to_projection;
166167

167168
if (cache_it != ctx.avro_to_projection_cache.end()) {
168169
// Use cached mapping
169170
avro_to_projection = &cache_it->second;
170171
} else {
171-
// Build and cache the mapping
172-
auto [inserted_it, inserted] = ctx.avro_to_projection_cache.emplace(
173-
cache_key, std::vector<int>(avro_node->leaves(), -1));
174-
avro_to_projection = &inserted_it->second;
175-
172+
// Build and validate the mapping before caching so a failure leaves no partial entry
173+
std::vector<int> mapping(avro_node->leaves(), -1);
176174
for (size_t proj_idx = 0; proj_idx < projections.size(); ++proj_idx) {
177175
const auto& field_projection = projections[proj_idx];
178176
if (field_projection.kind == FieldProjection::Kind::kProjected) {
179177
size_t avro_field_index = std::get<size_t>(field_projection.from);
180-
(*avro_to_projection)[avro_field_index] = static_cast<int>(proj_idx);
178+
if (avro_field_index >= mapping.size()) {
179+
return InvalidArgument(
180+
"Projected field index {} out of range for Avro record with {} fields",
181+
avro_field_index, mapping.size());
182+
}
183+
mapping[avro_field_index] = static_cast<int>(proj_idx);
181184
}
182185
}
186+
avro_to_projection =
187+
&ctx.avro_to_projection_cache.emplace(cache_key, std::move(mapping))
188+
.first->second;
183189
}
184190

185191
// Read all Avro fields in order (must maintain decoder position)

‎src/iceberg/avro/avro_direct_decoder_internal.h‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@
1919

2020
#pragma once
2121

22+
#include <cstddef>
23+
#include <functional>
24+
#include <unordered_map>
25+
#include <utility>
26+
#include <vector>
27+
2228
#include <arrow/array/builder_base.h>
2329
#include <avro/Decoder.hh>
2430
#include <avro/Node.hh>
@@ -41,9 +47,17 @@ struct DecodeContext {
4147
// Scratch buffer for binary/fixed/uuid/decimal data (reused across rows)
4248
std::vector<uint8_t> bytes_scratch;
4349
// Cache for avro field index to projection index mapping
44-
// Key: pointer to projections array (identifies struct schema)
50+
// Key: {projections array, Avro record node}; empty structs share a null data()
4551
// Value: vector mapping avro field index -> projection index (-1 if not projected)
46-
std::unordered_map<const FieldProjection*, std::vector<int>> avro_to_projection_cache;
52+
using ProjectionCacheKey = std::pair<const FieldProjection*, const ::avro::Node*>;
53+
struct ProjectionCacheKeyHash {
54+
size_t operator()(const ProjectionCacheKey& key) const noexcept {
55+
return std::hash<const FieldProjection*>{}(key.first) * 31 +
56+
std::hash<const ::avro::Node*>{}(key.second);
57+
}
58+
};
59+
std::unordered_map<ProjectionCacheKey, std::vector<int>, ProjectionCacheKeyHash>
60+
avro_to_projection_cache;
4761
};
4862

4963
/// \brief Directly decode Avro data to Arrow array builders without GenericDatum

‎src/iceberg/test/avro_data_test.cc‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
#include <gtest/gtest.h>
4040

4141
#include "iceberg/avro/avro_data_util_internal.h"
42+
#include "iceberg/avro/avro_direct_decoder_internal.h"
4243
#include "iceberg/avro/avro_direct_encoder_internal.h"
4344
#include "iceberg/avro/avro_schema_util_internal.h"
4445
#include "iceberg/expression/literal.h"
@@ -1900,6 +1901,55 @@ const std::vector<RoundTripParam> kRoundTripTestCases = {
19001901
},
19011902
};
19021903

1904+
TEST(DirectDecoderTest, ProjectionCacheKeyedPerAvroNodeForEmptyStructs) {
1905+
auto make_struct = [](int32_t first_id, int count) {
1906+
std::vector<SchemaField> fields;
1907+
for (int i = 0; i < count; ++i) {
1908+
fields.push_back(SchemaField::MakeRequired(first_id + i, "f" + std::to_string(i),
1909+
std::make_shared<IntType>()));
1910+
}
1911+
return std::make_shared<StructType>(std::move(fields));
1912+
};
1913+
Schema file_schema({SchemaField::MakeRequired(1, "narrow", make_struct(10, 1)),
1914+
SchemaField::MakeRequired(2, "wide", make_struct(20, 6))});
1915+
Schema read_schema({SchemaField::MakeRequired(1, "narrow", make_struct(10, 0)),
1916+
SchemaField::MakeRequired(2, "wide", make_struct(20, 0))});
1917+
1918+
::avro::NodePtr avro_node;
1919+
ASSERT_THAT(ToAvroNodeVisitor{}.Visit(file_schema, &avro_node), IsOk());
1920+
ICEBERG_UNWRAP_OR_FAIL(auto projection,
1921+
Project(read_schema, avro_node, /*prune_source=*/false));
1922+
1923+
auto out = ::avro::memoryOutputStream();
1924+
auto encoder = ::avro::binaryEncoder();
1925+
encoder->init(*out);
1926+
for (int i = 0; i < 7; ++i) {
1927+
encoder->encodeInt(i);
1928+
}
1929+
encoder->flush();
1930+
auto in = ::avro::memoryInputStream(*out);
1931+
auto decoder = ::avro::binaryDecoder();
1932+
decoder->init(*in);
1933+
1934+
ArrowSchema arrow_c_schema;
1935+
ASSERT_THAT(ToArrowSchema(read_schema, &arrow_c_schema), IsOk());
1936+
auto arrow_schema = ::arrow::ImportSchema(&arrow_c_schema).ValueOrDie();
1937+
auto builder =
1938+
::arrow::MakeBuilder(std::make_shared<::arrow::StructType>(arrow_schema->fields()))
1939+
.ValueOrDie();
1940+
1941+
DecodeContext ctx;
1942+
ASSERT_THAT(DecodeAvroToBuilder(avro_node, *decoder, projection, read_schema,
1943+
/*metadata_context=*/{}, builder.get(), ctx),
1944+
IsOk());
1945+
1946+
std::vector<size_t> mapping_sizes;
1947+
for (const auto& [key, mapping] : ctx.avro_to_projection_cache) {
1948+
mapping_sizes.push_back(mapping.size());
1949+
}
1950+
EXPECT_THAT(mapping_sizes, ::testing::UnorderedElementsAre(1, 6, 2));
1951+
}
1952+
19031953
INSTANTIATE_TEST_SUITE_P(AllTypes, AvroRoundTripConversionTest,
19041954
::testing::ValuesIn(kRoundTripTestCases),
19051955
[](const ::testing::TestParamInfo<RoundTripParam>& info) {

‎src/iceberg/test/avro_test.cc‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
*/
1919

2020
#include <array>
21+
#include <format>
2122
#include <initializer_list>
2223
#include <optional>
2324
#include <sstream>
@@ -399,6 +400,54 @@ TEST_F(AvroReaderTest, ReadWithBatchSize) {
399400
ASSERT_NO_FATAL_FAILURE(VerifyExhausted(*reader));
400401
}
401402

403+
TEST_F(AvroReaderTest, ProjectEmptyStructsOfDifferentWidths) {
404+
auto make_struct = [](int32_t first_id, int count) {
405+
std::vector<SchemaField> fields;
406+
for (int i = 0; i < count; ++i) {
407+
fields.push_back(SchemaField::MakeRequired(first_id + i, std::format("f{}", i),
408+
std::make_shared<IntType>()));
409+
}
410+
return std::make_shared<StructType>(std::move(fields));
411+
};
412+
auto write_schema = std::make_shared<Schema>(
413+
std::vector<SchemaField>{SchemaField::MakeRequired(1, "narrow", make_struct(10, 1)),
414+
SchemaField::MakeRequired(2, "wide", make_struct(20, 6))});
415+
416+
ArrowSchema arrow_c_schema;
417+
ASSERT_THAT(ToArrowSchema(*write_schema, &arrow_c_schema), IsOk());
418+
auto arrow_schema = ::arrow::ImportType(&arrow_c_schema).ValueOrDie();
419+
auto array =
420+
::arrow::json::ArrayFromJSONString(
421+
arrow_schema, R"([[[1], [1, 2, 3, 4, 5, 6]], [[7], [7, 8, 9, 10, 11, 12]]])")
422+
.ValueOrDie();
423+
struct ArrowArray arrow_array;
424+
ASSERT_TRUE(::arrow::ExportArray(*array, &arrow_array).ok());
425+
426+
auto writer_result = WriterFactoryRegistry::Open(
427+
FileFormatType::kAvro,
428+
{.path = temp_avro_file_, .schema = write_schema, .io = file_io_});
429+
ASSERT_THAT(writer_result, IsOk());
430+
auto writer = std::move(writer_result.value());
431+
ASSERT_THAT(writer->Write(&arrow_array), IsOk());
432+
ASSERT_THAT(writer->Close(), IsOk());
433+
434+
auto read_schema = std::make_shared<Schema>(
435+
std::vector<SchemaField>{SchemaField::MakeRequired(1, "narrow", make_struct(10, 0)),
436+
SchemaField::MakeRequired(2, "wide", make_struct(20, 0))});
437+
ReaderProperties reader_properties;
438+
reader_properties.Set(ReaderProperties::kAvroSkipDatum, true);
439+
auto reader_result = ReaderFactoryRegistry::Open(
440+
FileFormatType::kAvro, {.path = temp_avro_file_,
441+
.io = file_io_,
442+
.projection = read_schema,
443+
.properties = std::move(reader_properties)});
444+
ASSERT_THAT(reader_result, IsOk());
445+
auto reader = std::move(reader_result.value());
446+
447+
ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, R"([[[], []], [[], []]])"));
448+
ASSERT_NO_FATAL_FAILURE(VerifyExhausted(*reader));
449+
}
450+
402451
TEST_F(AvroReaderTest, BufferSizeConfiguration) {
403452
// Test default buffer size
404453
ReaderProperties properties1;

0 commit comments

Comments
 (0)