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

numinnex pushed a commit to branch rework_topic_commands
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/rework_topic_commands by this 
push:
     new 8245d0d3c add limit to the max key value pair entries
8245d0d3c is described below

commit 8245d0d3c442efb2d4eda4fbedbded4f0e125bf0
Author: Grzegorz Koszyk <[email protected]>
AuthorDate: Fri Aug 14 07:36:59 2026 +0200

    add limit to the max key value pair entries
---
 core/binary_protocol/src/lib.rs                |  4 +--
 core/binary_protocol/src/primitives/options.rs | 46 +++++++++++++++-----------
 core/common/src/types/options/mod.rs           | 26 +++++++++++++++
 foreign/node/src/wire/options.utils.ts         | 34 +++++++++++--------
 4 files changed, 74 insertions(+), 36 deletions(-)

diff --git a/core/binary_protocol/src/lib.rs b/core/binary_protocol/src/lib.rs
index 72540d792..9d478f996 100644
--- a/core/binary_protocol/src/lib.rs
+++ b/core/binary_protocol/src/lib.rs
@@ -89,9 +89,7 @@ pub use message_view::{
 pub use primitives::ack_level::AckLevel;
 pub use primitives::consumer::{KIND_CONSUMER_GROUP, WireConsumer};
 pub use primitives::identifier::{MAX_WIRE_NAME_LENGTH, WireIdentifier, 
WireName};
-pub use primitives::options::{
-    MAX_OPTION_KEY_LENGTH, MAX_OPTIONS, MAX_OPTIONS_BYTES, WireOptions, 
validate_options,
-};
+pub use primitives::options::{MAX_OPTIONS, MAX_OPTIONS_BYTES, WireOptions, 
validate_options};
 pub use primitives::partition_assignment::CreatedPartitionAssignment;
 pub use primitives::partitioning::{MAX_MESSAGES_KEY_LENGTH, WirePartitioning};
 pub use primitives::permissions::{
diff --git a/core/binary_protocol/src/primitives/options.rs 
b/core/binary_protocol/src/primitives/options.rs
index e14724776..9b76423bd 100644
--- a/core/binary_protocol/src/primitives/options.rs
+++ b/core/binary_protocol/src/primitives/options.rs
@@ -36,13 +36,21 @@ use crate::primitives::user_headers::{
 };
 
 /// Maximum number of key-value entries in one options block.
-pub const MAX_OPTIONS: u32 = 64;
-
-/// Maximum byte length of an option key.
-pub const MAX_OPTION_KEY_LENGTH: usize = 64;
+pub const MAX_OPTIONS: u32 = 1024;
 
 /// Maximum total byte length of an encoded options block.
-pub const MAX_OPTIONS_BYTES: usize = 4096;
+///
+/// Mirrors `iggy_common::MAX_USER_HEADERS_SIZE`: options ride the user-headers
+/// codec, so they inherit its budget rather than inventing a second one. The
+/// constant is duplicated because `iggy_common` depends on this crate, not the
+/// other way round; `options_block_budget_matches_user_headers` pins the two
+/// together from the side that can see both.
+///
+/// Sized so [`MAX_OPTIONS`] is actually reachable: the cheapest entry is 12
+/// bytes (kind + length + one byte, for key and value each), so 1024 entries
+/// need at least 12 KiB. A smaller budget would silently cap the entry count
+/// below `MAX_OPTIONS` and make that limit a lie.
+pub const MAX_OPTIONS_BYTES: usize = 100 * 1000;
 
 /// Wire kind code for UTF-8 string fields, matching `HeaderKind::String`
 /// in `iggy_common`.
@@ -54,7 +62,7 @@ const STRING_KIND: WireHeaderKind = WireHeaderKind(2);
 ///
 /// - Total block size within [`MAX_OPTIONS_BYTES`]
 /// - At most [`MAX_OPTIONS`] entries
-/// - Every key is a UTF-8 string of at most [`MAX_OPTION_KEY_LENGTH`] bytes
+/// - Every key is a UTF-8 string (length already bounded by the codec)
 /// - No duplicate keys
 ///
 /// Value kind codes are deliberately NOT restricted to the currently defined
@@ -92,12 +100,6 @@ pub fn validate_options(buf: &[u8]) -> Result<u32, 
WireError> {
                 entry.key_kind.0
             ))));
         }
-        if entry.key.len() > MAX_OPTION_KEY_LENGTH {
-            return Err(WireError::Validation(Cow::Owned(format!(
-                "option key is {} bytes, exceeds maximum 
{MAX_OPTION_KEY_LENGTH}",
-                entry.key.len()
-            ))));
-        }
         if std::str::from_utf8(entry.key).is_err() {
             return Err(WireError::Validation(Cow::Borrowed(
                 "option key is not valid UTF-8",
@@ -321,16 +323,19 @@ mod tests {
     }
 
     #[test]
-    fn key_over_length_limit_is_rejected() {
-        let key = [b'k'; MAX_OPTION_KEY_LENGTH + 1];
-        let buf = encode(&[(STRING, &key, STRING, b"value")]);
+    fn key_length_is_bounded_by_the_user_headers_codec() {
+        // Options add no key-length rule of their own: every field is already
+        // bounded to 1..=255 by `validate_user_headers`, so the codec's limit
+        // is the option key's limit.
+        let over = [b'k'; 256];
+        let buf = encode(&[(STRING, &over, STRING, b"value")]);
         assert!(matches!(
             validate_options(&buf),
             Err(WireError::Validation(_))
         ));
 
-        let max_key = [b'k'; MAX_OPTION_KEY_LENGTH];
-        let buf = encode(&[(STRING, &max_key, STRING, b"value")]);
+        let at_limit = [b'k'; 255];
+        let buf = encode(&[(STRING, &at_limit, STRING, b"value")]);
         assert_eq!(validate_options(&buf).unwrap(), 1);
     }
 
@@ -353,8 +358,11 @@ mod tests {
 
     #[test]
     fn block_over_byte_limit_is_rejected() {
-        let value = [b'v'; 250];
-        let keys: Vec<String> = (0..16).map(|i| format!("key_{i}")).collect();
+        // 200 maximum-size entries are ~104 KB, over the byte budget while
+        // still well under `MAX_OPTIONS`, so this proves the byte cap trips
+        // independently of the entry count.
+        let value = [b'v'; 255];
+        let keys: Vec<String> = (0..200).map(|i| 
format!("{i:0>255}")).collect();
         let entries: Vec<(u8, &[u8], u8, &[u8])> = keys
             .iter()
             .map(|key| (STRING, key.as_bytes(), STRING, value.as_slice()))
diff --git a/core/common/src/types/options/mod.rs 
b/core/common/src/types/options/mod.rs
index 584aa94d8..d84c3489b 100644
--- a/core/common/src/types/options/mod.rs
+++ b/core/common/src/types/options/mod.rs
@@ -1125,4 +1125,30 @@ mod tests {
         );
         assert!(parsed.max_topic_size.is_some());
     }
+
+    #[test]
+    fn options_block_budget_matches_user_headers() {
+        // `MAX_OPTIONS_BYTES` duplicates `MAX_USER_HEADERS_SIZE` because
+        // `iggy_binary_protocol` cannot import this crate. This is the only
+        // place both are visible, so it is where they get tied together.
+        assert_eq!(
+            iggy_binary_protocol::MAX_OPTIONS_BYTES,
+            crate::MAX_USER_HEADERS_SIZE as usize,
+            "options inherit the user-headers byte budget; update both"
+        );
+    }
+
+    #[test]
+    fn max_options_is_reachable_within_the_byte_budget() {
+        // The cheapest entry is 12 bytes: kind + u32 length + one byte, for
+        // key and value each. If the budget ever drops below this product the
+        // entry cap becomes unreachable and `MAX_OPTIONS` stops being the
+        // limit that actually binds.
+        const MIN_ENTRY_BYTES: usize = 2 * (1 + 4 + 1);
+        assert!(
+            iggy_binary_protocol::MAX_OPTIONS as usize * MIN_ENTRY_BYTES
+                <= iggy_binary_protocol::MAX_OPTIONS_BYTES,
+            "MAX_OPTIONS entries must fit in MAX_OPTIONS_BYTES"
+        );
+    }
 }
diff --git a/foreign/node/src/wire/options.utils.ts 
b/foreign/node/src/wire/options.utils.ts
index 65c8c22be..cb38b2589 100644
--- a/foreign/node/src/wire/options.utils.ts
+++ b/foreign/node/src/wire/options.utils.ts
@@ -26,13 +26,22 @@ import {
 } from './message/header.utils.js';
 
 /** Maximum number of key-value entries in one options block. */
-export const MAX_OPTIONS = 64;
+export const MAX_OPTIONS = 1024;
 
-/** Maximum byte length of an option key. */
-export const MAX_OPTION_KEY_LENGTH = 64;
+/**
+ * Maximum total byte length of an encoded options block.
+ *
+ * Mirrors the Rust `MAX_OPTIONS_BYTES`, which in turn mirrors the user-headers
+ * budget: options ride that codec and inherit its limit.
+ */
+export const MAX_OPTIONS_BYTES = 100 * 1000;
 
-/** Maximum total byte length of an encoded options block. */
-export const MAX_OPTIONS_BYTES = 4096;
+/**
+ * Key and value length bound, inherited from the header-field codec rather
+ * than being an options-specific rule (`serializeHeaders` enforces the same
+ * range on the way out).
+ */
+const MAX_HEADER_FIELD_LENGTH = 255;
 
 /** A resource option entry: UTF-8 string key with a typed value. */
 export type OptionEntry = {
@@ -66,13 +75,10 @@ export const serializeOptions = (options: OptionEntry[]): 
Buffer => {
     throw new Error(
       `Options block has ${options.length} entries, exceeds maximum 
${MAX_OPTIONS}`);
 
-  const block = serializeHeaders(options.map(({ key, value }) => {
-    const keyLength = Buffer.byteLength(key);
-    if (keyLength < 1 || keyLength > MAX_OPTION_KEY_LENGTH)
-      throw new Error(
-        `Option key should be between 1 and ${MAX_OPTION_KEY_LENGTH} bytes`);
-    return { key: HeaderKeyFactory.String(key), value };
-  }));
+  // No key-length check here: `serializeHeaders` already bounds every field
+  // to 1..=255, so an options-specific cap would only duplicate it.
+  const block = serializeHeaders(options.map(({ key, value }) =>
+    ({ key: HeaderKeyFactory.String(key), value })));
 
   if (block.length > MAX_OPTIONS_BYTES)
     throw new Error(
@@ -99,10 +105,10 @@ export const deserializeOptions = (
     if (keyKind !== HeaderKind.String)
       throw new Error(`Option key kind ${keyKind} is not a string`);
     const keyLength = p.readUInt32LE(pos + 1);
-    if (keyLength < 1 || keyLength > MAX_OPTION_KEY_LENGTH)
+    if (keyLength < 1 || keyLength > MAX_HEADER_FIELD_LENGTH)
       throw new Error(
         `Invalid option key length: ${keyLength}, ` +
-        `must be between 1 and ${MAX_OPTION_KEY_LENGTH}`);
+        `must be between 1 and ${MAX_HEADER_FIELD_LENGTH}`);
     if (pos + 5 + keyLength > end)
       throw new Error('Option key overruns the block');
     const key = p.subarray(pos + 5, pos + 5 + keyLength).toString();

Reply via email to