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

CurtHagenlocher pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-dotnet.git


The following commit(s) were added to refs/heads/main by this push:
     new 1abe8ab  fix: encoding of Variant object header field-id and offset 
sizes (#421)
1abe8ab is described below

commit 1abe8ab88d5ac3f1bb660d610ed4aec6c974e8ad
Author: Robert Yokota <[email protected]>
AuthorDate: Sat Aug 22 06:34:34 2026 -0700

    fix: encoding of Variant object header field-id and offset sizes (#421)
    
    ### What
    
    `VariantEncodingHelper` wrote and read the Variant object value header
    with
    `field_id_size_minus_one` and `field_offset_size_minus_one` in each
    other's bit positions.
    
    Per `apache/parquet-format` `VariantEncoding.md`, the object
    `value_header` — the 6 bits above
    the 2 basic-type bits — is laid out as:
    
    ```
                      5   4  3     2 1     0
                    +---+---+-------+-------+
    value_header    | R |   |       |       |
                    +---+---+-------+-------+
                          ^     ^       ^
                          |     |       +-- field_offset_size_minus_one
                          |     +-- field_id_size_minus_one
                          +-- is_large
    ```
    
    `MakeObjectHeader` and `ParseObjectHeader` had the two 2-bit fields
    transposed, and the layout
    comment above them documented the same transposition — so the block was
    internally consistent
    rather than wrong in one expression.
    
    `is_large` was already correct. The array header and metadata header
    helpers were checked and
    match the spec. This affects the object header only.
    
    ### Impact
    
    Reader and writer shared the inverted convention, so arrow-dotnet
    round-tripped its own output
    correctly. The bug was only observable across implementations, and only
    when
    `fieldIdSize != offsetSize` — when the two are equal, transposing them
    is a no-op.
    
    Those sizes are computed independently in `VariantValueWriter`
    (`fieldIdSize` from the maximum
    field ID, `offsetSize` from the encoded data length), so they diverge
    routinely: for example an
    object drawn from a >255-entry metadata dictionary (2-byte field IDs)
    whose own field data is
    under 256 bytes (1-byte offsets).
    
    For `fieldIdSize=2, offsetSize=1, isLarge=false`, the spec-correct
    header byte is `0x12`;
    before this change we emitted `0x06`, and read `0x12` back as
    `fieldIdSize=1, offsetSize=2`.
    Such objects were silently misparsed in both directions — field IDs and
    offsets read at the
    wrong widths, surfacing as garbage field values or out-of-range offsets
    rather than a clean
    error.
    
    ### Changes
    
    - `VariantEncodingHelper.MakeObjectHeader` / `ParseObjectHeader`: swap
    the two shifts, and
    correct the layout comment. The `out` parameters were already named
    correctly, so neither
    call site — `VariantValueWriter` or `VariantObjectReader` — needed
    changes.
    - `VariantEncodingHelperTests`: add `MakeObjectHeaderUsesSpecBitLayout`
    and
    
    Closes #420.
---
 .../Variant/VariantEncodingHelper.cs               | 12 +++---
 test/Apache.Arrow.Scalars.Tests/TestVectors.cs     | 31 ++++++++++++++
 .../VariantEncodingHelperTests.cs                  | 39 ++++++++++++++++++
 .../VariantReaderObjectTests.cs                    | 47 ++++++++++++++++++++++
 4 files changed, 123 insertions(+), 6 deletions(-)

diff --git a/src/Apache.Arrow.Scalars/Variant/VariantEncodingHelper.cs 
b/src/Apache.Arrow.Scalars/Variant/VariantEncodingHelper.cs
index 6975bfb..5e87c21 100644
--- a/src/Apache.Arrow.Scalars/Variant/VariantEncodingHelper.cs
+++ b/src/Apache.Arrow.Scalars/Variant/VariantEncodingHelper.cs
@@ -104,8 +104,8 @@ namespace Apache.Arrow.Scalars.Variant
         // Object value header
         // ---------------------------------------------------------------
         //
-        //  Bits 2-3: field_id_size - 1 (0-3 => 1-4 bytes)
-        //  Bits 4-5: field_offset_size - 1 (0-3 => 1-4 bytes)
+        //  Bits 2-3: field_offset_size - 1 (0-3 => 1-4 bytes)
+        //  Bits 4-5: field_id_size - 1 (0-3 => 1-4 bytes)
         //  Bit  6:   is_large (0 = 1-byte num_fields, 1 = 4-byte num_fields)
         //  Bit  7:   unused (must be 0)
 
@@ -118,8 +118,8 @@ namespace Apache.Arrow.Scalars.Variant
         public static byte MakeObjectHeader(int fieldIdSize, int offsetSize, 
bool isLarge)
         {
             int valueHeader =
-                ((fieldIdSize - 1) & 0x03) |
-                (((offsetSize - 1) & 0x03) << 2) |
+                ((offsetSize - 1) & 0x03) |
+                (((fieldIdSize - 1) & 0x03) << 2) |
                 ((isLarge ? 1 : 0) << 4);
             return MakeValueHeader(VariantBasicType.Object, valueHeader);
         }
@@ -130,8 +130,8 @@ namespace Apache.Arrow.Scalars.Variant
         public static void ParseObjectHeader(byte header, out int fieldIdSize, 
out int offsetSize, out bool isLarge)
         {
             int valueHeader = GetValueHeader(header);
-            fieldIdSize = (valueHeader & 0x03) + 1;
-            offsetSize = ((valueHeader >> 2) & 0x03) + 1;
+            offsetSize = (valueHeader & 0x03) + 1;
+            fieldIdSize = ((valueHeader >> 2) & 0x03) + 1;
             isLarge = ((valueHeader >> 4) & 0x01) != 0;
         }
 
diff --git a/test/Apache.Arrow.Scalars.Tests/TestVectors.cs 
b/test/Apache.Arrow.Scalars.Tests/TestVectors.cs
index 3c81622..70e7e14 100644
--- a/test/Apache.Arrow.Scalars.Tests/TestVectors.cs
+++ b/test/Apache.Arrow.Scalars.Tests/TestVectors.cs
@@ -325,6 +325,37 @@ namespace Apache.Arrow.Scalars.Tests
             (byte)'B', (byte)'o', (byte)'b',
         };
 
+        /// <summary>
+        /// The same object as <see cref="Object_Age30_Name_Bob"/> — {"age": 
30, "name": "Bob"} —
+        /// but encoded with field_id_size=2 and offset_size=1.
+        ///
+        /// Every other object vector here uses field_id_size == offset_size, 
where swapping the
+        /// two header fields is indistinguishable. This one does not, so it 
detects a reader that
+        /// reads the two size fields from each other's bits: such a reader 
sees field_id_size=1
+        /// and offset_size=2 and walks the id and offset lists at the wrong 
widths.
+        ///
+        /// A wider-than-minimal field_id_size is legal; the spec requires 
readers to honor the
+        /// width declared in the header.
+        /// </summary>
+        public static ReadOnlySpan<byte> Object_Age30_Name_Bob_WideFieldIds => 
new byte[]
+        {
+            0x12,       // header: basic_type=Object(2), fid_size=2, 
off_size=1, is_large=false
+                        //   value_header = (fid_size-1) << 2 | (off_size-1) = 
0b000100 = 4
+                        //   header byte  = (4 << 2) | 2 = 0x12
+            0x02,       // num_fields = 2
+            0x00, 0x00, // field_id[0] = 0 (=> "age"), 2 bytes little-endian
+            0x01, 0x00, // field_id[1] = 1 (=> "name"), 2 bytes little-endian
+            0x00,       // offset[0] = 0
+            0x02,       // offset[1] = 2
+            0x06,       // end_offset = 6
+            // value 0: Int8 = 30
+            0x0C,       // primitive Int8 header
+            0x1E,       // 30
+            // value 1: short string "Bob"
+            0x0D,       // basic_type=ShortString(1), length=3 => (3 << 2) | 1 
= 13
+            (byte)'B', (byte)'o', (byte)'b',
+        };
+
         // =================================================================
         // Array test vectors
         // =================================================================
diff --git a/test/Apache.Arrow.Scalars.Tests/VariantEncodingHelperTests.cs 
b/test/Apache.Arrow.Scalars.Tests/VariantEncodingHelperTests.cs
index f95bbf2..83589d9 100644
--- a/test/Apache.Arrow.Scalars.Tests/VariantEncodingHelperTests.cs
+++ b/test/Apache.Arrow.Scalars.Tests/VariantEncodingHelperTests.cs
@@ -97,6 +97,45 @@ namespace Apache.Arrow.Scalars.Tests
             Assert.Equal(isLarge, parsedIsLarge);
         }
 
+        // The round-trip test above cannot detect the field-id and offset 
size bits being
+        // swapped, because MakeObjectHeader and ParseObjectHeader would share 
the mistake. These
+        // two pin each direction to literal bytes taken from the spec's 
layout instead:
+        //
+        //   value_header bits 0-1 = field_offset_size - 1
+        //   value_header bits 2-3 = field_id_size - 1
+        //   value_header bit  4   = is_large
+        //   header byte = (value_header << 2) | Object(2)
+
+        [Theory]
+        [InlineData(1, 1, false, 0x02)]
+        [InlineData(2, 1, false, 0x12)]
+        [InlineData(1, 2, false, 0x06)]
+        [InlineData(2, 3, false, 0x1A)]
+        [InlineData(4, 1, true, 0x72)]
+        [InlineData(1, 4, true, 0x4E)]
+        [InlineData(4, 4, true, 0x7E)]
+        public void MakeObjectHeaderUsesSpecBitLayout(int fieldIdSize, int 
offsetSize, bool isLarge, int expected)
+        {
+            byte header = VariantEncodingHelper.MakeObjectHeader(fieldIdSize, 
offsetSize, isLarge);
+            Assert.Equal(expected, (int)header);
+        }
+
+        [Theory]
+        [InlineData(0x02, 1, 1, false)]
+        [InlineData(0x12, 2, 1, false)]
+        [InlineData(0x06, 1, 2, false)]
+        [InlineData(0x1A, 2, 3, false)]
+        [InlineData(0x72, 4, 1, true)]
+        [InlineData(0x4E, 1, 4, true)]
+        [InlineData(0x7E, 4, 4, true)]
+        public void ParseObjectHeaderUsesSpecBitLayout(int header, int 
expectedFieldIdSize, int expectedOffsetSize, bool expectedIsLarge)
+        {
+            VariantEncodingHelper.ParseObjectHeader((byte)header, out int 
fieldIdSize, out int offsetSize, out bool isLarge);
+            Assert.Equal(expectedFieldIdSize, fieldIdSize);
+            Assert.Equal(expectedOffsetSize, offsetSize);
+            Assert.Equal(expectedIsLarge, isLarge);
+        }
+
         // ---------------------------------------------------------------
         // Array headers
         // ---------------------------------------------------------------
diff --git a/test/Apache.Arrow.Scalars.Tests/VariantReaderObjectTests.cs 
b/test/Apache.Arrow.Scalars.Tests/VariantReaderObjectTests.cs
index 10d72ef..37c8202 100644
--- a/test/Apache.Arrow.Scalars.Tests/VariantReaderObjectTests.cs
+++ b/test/Apache.Arrow.Scalars.Tests/VariantReaderObjectTests.cs
@@ -150,6 +150,53 @@ namespace Apache.Arrow.Scalars.Tests
             Assert.False(obj.TryGetField("email", out VariantReader _));
         }
 
+        // ---------------------------------------------------------------
+        // Asymmetric header sizes
+        // ---------------------------------------------------------------
+        //
+        // Objects whose field_id_size and offset_size differ are the only 
ones that can detect
+        // the two header size fields being read from each other's bits. Every 
vector above uses
+        // field_id_size == offset_size, where the two layouts coincide.
+
+        [Fact]
+        public void WideFieldIds_FieldNames()
+        {
+            VariantObjectReader obj = new VariantObjectReader(
+                TestVectors.SortedMetadata_Age_Name, 
TestVectors.Object_Age30_Name_Bob_WideFieldIds);
+
+            Assert.Equal(2, obj.FieldCount);
+            Assert.Equal("age", obj.GetFieldName(0));
+            Assert.Equal("name", obj.GetFieldName(1));
+        }
+
+        [Fact]
+        public void WideFieldIds_FieldValues()
+        {
+            VariantObjectReader obj = new VariantObjectReader(
+                TestVectors.SortedMetadata_Age_Name, 
TestVectors.Object_Age30_Name_Bob_WideFieldIds);
+
+            VariantReader ageValue = obj.GetFieldValue(0);
+            Assert.Equal(VariantPrimitiveType.Int8, ageValue.PrimitiveType);
+            Assert.Equal(30, ageValue.GetInt8());
+
+            VariantReader nameValue = obj.GetFieldValue(1);
+            Assert.True(nameValue.IsString);
+            Assert.Equal("Bob", nameValue.GetString());
+        }
+
+        [Fact]
+        public void WideFieldIds_TryGetField_Both()
+        {
+            VariantObjectReader obj = new VariantObjectReader(
+                TestVectors.SortedMetadata_Age_Name, 
TestVectors.Object_Age30_Name_Bob_WideFieldIds);
+
+            Assert.True(obj.TryGetField("age", out VariantReader ageValue));
+            Assert.Equal(30, ageValue.GetInt8());
+
+            Assert.True(obj.TryGetField("name", out VariantReader nameValue));
+            Assert.Equal("Bob", nameValue.GetString());
+        }
+
         // ---------------------------------------------------------------
         // Error cases
         // ---------------------------------------------------------------

Reply via email to