This is an automated email from the ASF dual-hosted git repository.

wgtmac pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new cbca7d26 fix(serde): reject invalid schema and partition IDs in JSON 
(#940)
cbca7d26 is described below

commit cbca7d268f5a96848eb40de5fa5bfe8c763e1e98
Author: kamcheungting-db <[email protected]>
AuthorDate: Sun Oct 11 08:24:58 2026 -0700

    fix(serde): reject invalid schema and partition IDs in JSON (#940)
---
 src/iceberg/json_serde.cc               |  37 +++++----
 src/iceberg/test/json_serde_test.cc     | 140 ++++++++++++++++++++++++++++++++
 src/iceberg/test/metadata_serde_test.cc |  18 ++++
 src/iceberg/util/json_util_internal.h   |  12 +++
 4 files changed, 190 insertions(+), 17 deletions(-)

diff --git a/src/iceberg/json_serde.cc b/src/iceberg/json_serde.cc
index c7d0cb43..c1c3ffb7 100644
--- a/src/iceberg/json_serde.cc
+++ b/src/iceberg/json_serde.cc
@@ -22,6 +22,7 @@
 #include <cstdint>
 #include <format>
 #include <map>
+#include <optional>
 #include <regex>
 #include <string>
 #include <unordered_set>
@@ -768,7 +769,7 @@ Result<std::unique_ptr<Type>> ListTypeFromJson(const 
nlohmann::json& json) {
   ICEBERG_ASSIGN_OR_RAISE(
       auto element_type,
       GetJsonValue<nlohmann::json>(json, kElement).and_then(TypeFromJson));
-  ICEBERG_ASSIGN_OR_RAISE(auto element_id, GetJsonValue<int32_t>(json, 
kElementId));
+  ICEBERG_ASSIGN_OR_RAISE(auto element_id, GetJsonInteger<int32_t>(json, 
kElementId));
   ICEBERG_ASSIGN_OR_RAISE(auto element_required,
                           GetJsonValue<bool>(json, kElementRequired));
 
@@ -784,8 +785,8 @@ Result<std::unique_ptr<Type>> MapTypeFromJson(const 
nlohmann::json& json) {
   ICEBERG_ASSIGN_OR_RAISE(
       auto value_type, GetJsonValue<nlohmann::json>(json, 
kValue).and_then(TypeFromJson));
 
-  ICEBERG_ASSIGN_OR_RAISE(auto key_id, GetJsonValue<int32_t>(json, kKeyId));
-  ICEBERG_ASSIGN_OR_RAISE(auto value_id, GetJsonValue<int32_t>(json, 
kValueId));
+  ICEBERG_ASSIGN_OR_RAISE(auto key_id, GetJsonInteger<int32_t>(json, kKeyId));
+  ICEBERG_ASSIGN_OR_RAISE(auto value_id, GetJsonInteger<int32_t>(json, 
kValueId));
   ICEBERG_ASSIGN_OR_RAISE(auto value_required, GetJsonValue<bool>(json, 
kValueRequired));
 
   SchemaField key_field(key_id, std::string(MapType::kKeyName), 
std::move(key_type),
@@ -946,7 +947,7 @@ Status ValidateTimestamptzDefaultIsUtc(const Type& type, 
const nlohmann::json& v
 Result<std::unique_ptr<SchemaField>> FieldFromJson(const nlohmann::json& json) 
{
   ICEBERG_ASSIGN_OR_RAISE(
       auto type, GetJsonValue<nlohmann::json>(json, 
kType).and_then(TypeFromJson));
-  ICEBERG_ASSIGN_OR_RAISE(auto field_id, GetJsonValue<int32_t>(json, kId));
+  ICEBERG_ASSIGN_OR_RAISE(auto field_id, GetJsonInteger<int32_t>(json, kId));
   ICEBERG_ASSIGN_OR_RAISE(auto name, GetJsonValue<std::string>(json, kName));
   ICEBERG_ASSIGN_OR_RAISE(auto required, GetJsonValue<bool>(json, kRequired));
   ICEBERG_ASSIGN_OR_RAISE(auto doc, GetJsonValueOrDefault<std::string>(json, 
kDoc));
@@ -979,7 +980,7 @@ Result<std::unique_ptr<SchemaField>> FieldFromJson(const 
nlohmann::json& json) {
 
 Result<std::unique_ptr<Schema>> SchemaFromJson(const nlohmann::json& json) {
   ICEBERG_ASSIGN_OR_RAISE(auto schema_id_opt,
-                          GetJsonValueOptional<int32_t>(json, kSchemaId));
+                          GetJsonIntegerOptional<int32_t>(json, kSchemaId));
   ICEBERG_ASSIGN_OR_RAISE(auto type, TypeFromJson(json));
 
   if (type->type_id() != TypeId::kStruct) [[unlikely]] {
@@ -993,9 +994,11 @@ Result<std::unique_ptr<Schema>> SchemaFromJson(const 
nlohmann::json& json) {
     fields.emplace_back(std::move(field));
   }
 
-  ICEBERG_ASSIGN_OR_RAISE(
-      auto identifier_field_ids,
-      GetJsonValueOrDefault<std::vector<int32_t>>(json, kIdentifierFieldIds));
+  std::vector<int32_t> identifier_field_ids;
+  if (json.contains(kIdentifierFieldIds) && 
!json.at(kIdentifierFieldIds).is_null()) {
+    ICEBERG_ASSIGN_OR_RAISE(identifier_field_ids,
+                            IntegerVectorFromJson<int32_t>(json, 
kIdentifierFieldIds));
+  }
 
   return Schema::Make(std::move(fields), 
schema_id_opt.value_or(Schema::kInitialSchemaId),
                       std::move(identifier_field_ids));
@@ -1028,14 +1031,13 @@ Result<std::string> ToJsonString(const PartitionSpec& 
partition_spec) {
 
 Result<std::unique_ptr<PartitionField>> PartitionFieldFromJson(
     const nlohmann::json& json, bool allow_field_id_missing) {
-  ICEBERG_ASSIGN_OR_RAISE(auto source_id, GetJsonValue<int32_t>(json, 
kSourceId));
+  ICEBERG_ASSIGN_OR_RAISE(auto source_id, GetJsonInteger<int32_t>(json, 
kSourceId));
   int32_t field_id;
-  if (allow_field_id_missing) {
+  if (allow_field_id_missing && !json.contains(kFieldId)) {
     // Partition field id in v1 is not tracked, so we use -1 to indicate that.
-    ICEBERG_ASSIGN_OR_RAISE(field_id, GetJsonValueOrDefault<int32_t>(
-                                          json, kFieldId, 
SchemaField::kInvalidFieldId));
+    field_id = SchemaField::kInvalidFieldId;
   } else {
-    ICEBERG_ASSIGN_OR_RAISE(field_id, GetJsonValue<int32_t>(json, kFieldId));
+    ICEBERG_ASSIGN_OR_RAISE(field_id, GetJsonInteger<int32_t>(json, kFieldId));
   }
   ICEBERG_ASSIGN_OR_RAISE(
       auto transform,
@@ -1067,13 +1069,13 @@ Result<std::unique_ptr<PartitionSpec>> 
ParseBoundPartitionSpec(
 Result<std::unique_ptr<PartitionSpec>> PartitionSpecFromJson(
     const std::shared_ptr<Schema>& schema, const nlohmann::json& json,
     int32_t default_spec_id) {
-  ICEBERG_ASSIGN_OR_RAISE(auto spec_id, GetJsonValue<int32_t>(json, kSpecId));
+  ICEBERG_ASSIGN_OR_RAISE(auto spec_id, GetJsonInteger<int32_t>(json, 
kSpecId));
   return ParseBoundPartitionSpec(*schema, json, spec_id,
                                  /*allow_missing_fields=*/spec_id != 
default_spec_id);
 }
 
 Result<std::unique_ptr<PartitionSpec>> PartitionSpecFromJson(const 
nlohmann::json& json) {
-  ICEBERG_ASSIGN_OR_RAISE(auto spec_id, GetJsonValue<int32_t>(json, kSpecId));
+  ICEBERG_ASSIGN_OR_RAISE(auto spec_id, GetJsonInteger<int32_t>(json, 
kSpecId));
   ICEBERG_ASSIGN_OR_RAISE(auto fields, GetJsonValue<nlohmann::json>(json, 
kFields));
 
   std::vector<PartitionField> partition_fields;
@@ -1438,7 +1440,7 @@ Result<std::shared_ptr<Schema>> ParseSchemas(
     }
 
     ICEBERG_ASSIGN_OR_RAISE(current_schema_id,
-                            GetJsonValue<int32_t>(json, kCurrentSchemaId));
+                            GetJsonInteger<int32_t>(json, kCurrentSchemaId));
     for (const auto& schema_json : schema_array) {
       ICEBERG_ASSIGN_OR_RAISE(std::shared_ptr<Schema> schema,
                               SchemaFromJson(schema_json));
@@ -1484,7 +1486,8 @@ Status ParsePartitionSpecs(const nlohmann::json& json, 
int8_t format_version,
       return JsonParseError("Cannot parse partition specs from non-array: {}",
                             SafeDumpJson(spec_array));
     }
-    ICEBERG_ASSIGN_OR_RAISE(default_spec_id, GetJsonValue<int32_t>(json, 
kDefaultSpecId));
+    ICEBERG_ASSIGN_OR_RAISE(default_spec_id,
+                            GetJsonInteger<int32_t>(json, kDefaultSpecId));
 
     for (const auto& spec_json : spec_array) {
       ICEBERG_ASSIGN_OR_RAISE(
diff --git a/src/iceberg/test/json_serde_test.cc 
b/src/iceberg/test/json_serde_test.cc
index be363ea3..51bc2af1 100644
--- a/src/iceberg/test/json_serde_test.cc
+++ b/src/iceberg/test/json_serde_test.cc
@@ -17,6 +17,7 @@
  * under the License.
  */
 
+#include <array>
 #include <memory>
 #include <optional>
 #include <string>
@@ -209,6 +210,72 @@ TEST(JsonInternalTest, ListTypeFromJsonMissingElement) {
   EXPECT_THAT(result, HasErrorMessage("Missing 'element'"));
 }
 
+TEST(JsonInternalTest, SchemaIdsRejectInvalidJsonIntegers) {
+  const nlohmann::json valid_schema = R"({
+    "type": "struct",
+    "schema-id": 10,
+    "identifier-field-ids": [20],
+    "fields": [
+      {"id": 20, "name": "id", "required": true, "type": "int"},
+      {
+        "id": 30,
+        "name": "items",
+        "required": false,
+        "type": {
+          "type": "list",
+          "element-id": 40,
+          "element-required": false,
+          "element": {
+            "type": "map",
+            "key-id": 50,
+            "key": "string",
+            "value-id": 60,
+            "value": "long",
+            "value-required": false
+          }
+        }
+      }
+    ]
+  })"_json;
+
+  struct IdPath {
+    nlohmann::json::json_pointer pointer;
+    std::string_view key;
+  };
+  const std::array<IdPath, 6> id_paths = {{
+      {.pointer = nlohmann::json::json_pointer("/schema-id"), .key = 
"schema-id"},
+      {.pointer = nlohmann::json::json_pointer("/identifier-field-ids/0"),
+       .key = "identifier-field-ids"},
+      {.pointer = nlohmann::json::json_pointer("/fields/0/id"), .key = "id"},
+      {.pointer = nlohmann::json::json_pointer("/fields/1/type/element-id"),
+       .key = "element-id"},
+      {.pointer = 
nlohmann::json::json_pointer("/fields/1/type/element/key-id"),
+       .key = "key-id"},
+      {.pointer = 
nlohmann::json::json_pointer("/fields/1/type/element/value-id"),
+       .key = "value-id"},
+  }};
+  const std::array<std::pair<nlohmann::json, std::string_view>, 5> invalid_ids 
= {{
+      {nlohmann::json(1.5), "must be an integer"},
+      {nlohmann::json(true), "must be an integer"},
+      {nlohmann::json(-2147483649LL), "out of range"},
+      {nlohmann::json(2147483648LL), "out of range"},
+      {nlohmann::json(2147483648ULL), "out of range"},
+  }};
+
+  for (const auto& id_path : id_paths) {
+    for (const auto& [invalid_id, expected_error] : invalid_ids) {
+      SCOPED_TRACE(id_path.pointer.to_string() + "=" + invalid_id.dump());
+      auto invalid_schema = valid_schema;
+      invalid_schema[id_path.pointer] = invalid_id;
+
+      auto result = SchemaFromJson(invalid_schema);
+      EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+      EXPECT_THAT(result, HasErrorMessage(id_path.key));
+      EXPECT_THAT(result, HasErrorMessage(expected_error));
+    }
+  }
+}
+
 TEST(JsonInternalTest, SortField) {
   auto identity_transform = Transform::Identity();
 
@@ -322,6 +389,79 @@ TEST(JsonInternalTest, PartitionSpecFromJson) {
   EXPECT_EQ(*spec, *parsed);
 }
 
+TEST(JsonInternalTest, PartitionIdsRejectInvalidJsonIntegers) {
+  const nlohmann::json valid_spec = R"({
+    "spec-id": 10,
+    "fields": [{
+      "source-id": 20,
+      "field-id": 1000,
+      "transform": "identity",
+      "name": "id"
+    }]
+  })"_json;
+  auto schema = std::make_shared<Schema>(
+      std::vector<SchemaField>{SchemaField(20, "id", int32(), false)},
+      /*schema_id=*/10);
+
+  struct IdPath {
+    nlohmann::json::json_pointer pointer;
+    std::string_view key;
+  };
+  const std::array<IdPath, 3> id_paths = {{
+      {.pointer = nlohmann::json::json_pointer("/spec-id"), .key = "spec-id"},
+      {.pointer = nlohmann::json::json_pointer("/fields/0/source-id"),
+       .key = "source-id"},
+      {.pointer = nlohmann::json::json_pointer("/fields/0/field-id"), .key = 
"field-id"},
+  }};
+  const std::array<std::pair<nlohmann::json, std::string_view>, 5> invalid_ids 
= {{
+      {nlohmann::json(1.5), "must be an integer"},
+      {nlohmann::json(true), "must be an integer"},
+      {nlohmann::json(-2147483649LL), "out of range"},
+      {nlohmann::json(2147483648LL), "out of range"},
+      {nlohmann::json(2147483648ULL), "out of range"},
+  }};
+
+  for (const auto& id_path : id_paths) {
+    for (const auto& [invalid_id, expected_error] : invalid_ids) {
+      SCOPED_TRACE(id_path.pointer.to_string() + "=" + invalid_id.dump());
+      auto invalid_spec = valid_spec;
+      invalid_spec[id_path.pointer] = invalid_id;
+
+      auto unbound_result = PartitionSpecFromJson(invalid_spec);
+      EXPECT_THAT(unbound_result, IsError(ErrorKind::kJsonParseError));
+      EXPECT_THAT(unbound_result, HasErrorMessage(id_path.key));
+      EXPECT_THAT(unbound_result, HasErrorMessage(expected_error));
+
+      auto bound_result = PartitionSpecFromJson(schema, invalid_spec, 10);
+      EXPECT_THAT(bound_result, IsError(ErrorKind::kJsonParseError));
+      EXPECT_THAT(bound_result, HasErrorMessage(id_path.key));
+      EXPECT_THAT(bound_result, HasErrorMessage(expected_error));
+    }
+  }
+
+  auto legacy_field = valid_spec["fields"][0];
+  legacy_field.erase("field-id");
+  ICEBERG_UNWRAP_OR_FAIL(
+      auto parsed_legacy_field,
+      PartitionFieldFromJson(legacy_field, /*allow_field_id_missing=*/true));
+  EXPECT_EQ(parsed_legacy_field->field_id(), SchemaField::kInvalidFieldId);
+
+  for (const auto& [invalid_id, expected_error] : invalid_ids) {
+    SCOPED_TRACE("legacy field-id=" + invalid_id.dump());
+    legacy_field["field-id"] = invalid_id;
+    auto result = PartitionFieldFromJson(legacy_field, 
/*allow_field_id_missing=*/true);
+    EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+    EXPECT_THAT(result, HasErrorMessage("field-id"));
+    EXPECT_THAT(result, HasErrorMessage(expected_error));
+  }
+
+  legacy_field["field-id"] = nullptr;
+  auto null_result =
+      PartitionFieldFromJson(legacy_field, /*allow_field_id_missing=*/true);
+  EXPECT_THAT(null_result, IsError(ErrorKind::kJsonParseError));
+  EXPECT_THAT(null_result, HasErrorMessage("field-id"));
+}
+
 TEST(JsonInternalTest, SnapshotRefBranch) {
   SnapshotRef ref(1234567890, SnapshotRef::Branch{.min_snapshots_to_keep = 10,
                                                   .max_snapshot_age_ms = 
123456789,
diff --git a/src/iceberg/test/metadata_serde_test.cc 
b/src/iceberg/test/metadata_serde_test.cc
index 10529d12..88272866 100644
--- a/src/iceberg/test/metadata_serde_test.cc
+++ b/src/iceberg/test/metadata_serde_test.cc
@@ -327,6 +327,24 @@ TEST(MetadataSerdeTest, DeserializeV2ValidMinimal) {
   ASSERT_FALSE(metadata->Snapshot().has_value());
 }
 
+TEST(MetadataSerdeTest, RejectsFractionalSchemaAndDefaultSpecIds) {
+  ICEBERG_UNWRAP_OR_FAIL(
+      auto metadata, 
ReadTableMetadataFromResource("TableMetadataV2ValidMinimal.json"));
+  ICEBERG_UNWRAP_OR_FAIL(auto valid_json, ToJson(*metadata));
+  ASSERT_THAT(TableMetadataFromJson(valid_json), IsOk());
+
+  for (const auto* key : {"current-schema-id", "default-spec-id"}) {
+    SCOPED_TRACE(key);
+    auto invalid_json = valid_json;
+    invalid_json[key] = 0.5;
+
+    auto result = TableMetadataFromJson(invalid_json);
+    EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+    EXPECT_THAT(result, HasErrorMessage(key));
+    EXPECT_THAT(result, HasErrorMessage("must be an integer"));
+  }
+}
+
 TEST(MetadataSerdeTest, DeserializeStatisticsFiles) {
   ICEBERG_UNWRAP_OR_FAIL(
       auto metadata, 
ReadTableMetadataFromResource("TableMetadataStatisticsFiles.json"));
diff --git a/src/iceberg/util/json_util_internal.h 
b/src/iceberg/util/json_util_internal.h
index 0c30b449..b1edf583 100644
--- a/src/iceberg/util/json_util_internal.h
+++ b/src/iceberg/util/json_util_internal.h
@@ -145,6 +145,18 @@ Result<Integer> GetJsonInteger(const nlohmann::json& json, 
std::string_view key)
   return IntegerFromJson<Integer>(value, std::format("'{}'", key));
 }
 
+/// \brief Parses an optional JSON integer field with `IntegerFromJson` 
strictness,
+/// returning `std::nullopt` when the key is missing or null.
+template <std::signed_integral Integer>
+Result<std::optional<Integer>> GetJsonIntegerOptional(const nlohmann::json& 
json,
+                                                      std::string_view key) {
+  if (!json.contains(key) || json.at(key).is_null()) {
+    return std::nullopt;
+  }
+  ICEBERG_ASSIGN_OR_RAISE(auto value, GetJsonInteger<Integer>(json, key));
+  return value;
+}
+
 /// \brief Parses an optional `int64_t` field, returning `default_value` when 
the key is
 /// missing or null.
 inline Result<int64_t> GetJsonIntegerOrDefault(const nlohmann::json& json,

Reply via email to