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

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


The following commit(s) were added to refs/heads/mtls_kafka_proxy by this push:
     new b277988ac address review
b277988ac is described below

commit b277988acdf599836f2df4595fc82433b2849ef4
Author: Grzegorz Koszyk <[email protected]>
AuthorDate: Thu Oct 15 09:59:56 2026 +0200

    address review
---
 gateways/kafka/README.md                       |   5 +-
 gateways/kafka/docs/AUTHENTICATION.md          |   6 +-
 gateways/kafka/src/bridge/iggy_bridge/fetch.rs |  40 ++++++++
 gateways/kafka/src/bridge/iggy_bridge/mod.rs   |  17 ++-
 gateways/kafka/src/tls/identity.rs             | 137 ++++++++++++++++++++++---
 5 files changed, 182 insertions(+), 23 deletions(-)

diff --git a/gateways/kafka/README.md b/gateways/kafka/README.md
index e55370350..bfcac9934 100644
--- a/gateways/kafka/README.md
+++ b/gateways/kafka/README.md
@@ -312,8 +312,9 @@ pattern matches the whole subject, `DEFAULT` keeps the 
subject as is. Iggy accep
 3 to 50 characters, but a raw subject is usually longer than that, and is 
rarely the name the
 operator created, so a deployment that maps identities sets at least one rule. 
Patterns are a
 Java-compatible subset: `\d \s \w` match ASCII only and `.` stops at Java's 
line terminators, as on
-a broker, while `(?i)`, the `u` and `U` flags, `\p{..}`, POSIX classes and 
`\b` are refused at
-startup ([docs/AUTHENTICATION.md](docs/AUTHENTICATION.md) says why). A 
replacement that
+a broker, while `(?i)`, the `u`, `U`, `m`, `x` and `R` flags, `\p{..}`, POSIX 
classes, `\b`, `\v`,
+possessive quantifiers and the `--` and `~~` class operations are refused at 
startup
+([docs/AUTHENTICATION.md](docs/AUTHENTICATION.md) says why). A replacement that
 names a group the pattern does not have is refused at startup. That is 
stricter than Kafka, which
 writes such a `$N` literally: `RULE:^CN=(.*)$/$2/` maps to `$2` on a broker. 
The reverse case,
 `/$1\$2/`, makes Kafka throw while the gateway maps `alice` to `alice$2`, 
which is harmless because
diff --git a/gateways/kafka/docs/AUTHENTICATION.md 
b/gateways/kafka/docs/AUTHENTICATION.md
index a217cb83c..31ee2d072 100644
--- a/gateways/kafka/docs/AUTHENTICATION.md
+++ b/gateways/kafka/docs/AUTHENTICATION.md
@@ -259,8 +259,10 @@ first frame:
    ASCII only, and `.` outside `(?s)` stops at `\r`, U+0085, U+2028 and U+2029 
as well as `\n`, both as
    in Java. Rust would read them over Unicode, so a CA-signed `CN=\u212Aafka` 
(Kelvin sign) would match
    `\w+` and lowercase to `kafka`. Case-insensitive matching (`(?i)`), the `u` 
and `U` flags, `\p{..}`
-   and `\P{..}`, POSIX classes such as `[[:alpha:]]`, and word boundaries 
(`\b`, `\B`) are refused at
-   startup with the construct named: each has no faithful translation. 
Non-ASCII names still map, so
+   and `\P{..}`, POSIX classes such as `[[:alpha:]]`, word boundaries (`\b`, 
`\B`), the `m`, `x` and
+   `R` flags, `\v`, a quantifier directly after another (`.*+`, possessive in 
Java) and the `--` and
+   `~~` class operations are refused at startup with the construct named: each 
has no faithful
+   translation. Non-ASCII names still map, so
    `CN=Zoë` under `RULE:^CN=([^,]+).*$/$1/` maps to `Zoë` as on a broker.
 3. **Look the name up** through the bridge's own session. The user must exist 
and be active, or the
    connection is closed after the handshake with the name in the log at 
`warn`: a certificate the CA signed
diff --git a/gateways/kafka/src/bridge/iggy_bridge/fetch.rs 
b/gateways/kafka/src/bridge/iggy_bridge/fetch.rs
index 9ce900bbb..e404d8fc4 100644
--- a/gateways/kafka/src/bridge/iggy_bridge/fetch.rs
+++ b/gateways/kafka/src/bridge/iggy_bridge/fetch.rs
@@ -129,6 +129,23 @@ impl LazyClient {
         result
     }
 
+    /// [`Self::until`] on a task of its own, so the deadline still drops a 
stuck client when the
+    /// caller stops waiting first.
+    pub(super) async fn until_detached<T, Call>(
+        &self,
+        deadline: Instant,
+        call: Call,
+    ) -> Result<T, BridgeError>
+    where
+        T: Send + 'static,
+        Call: Future<Output = Result<T, BridgeError>> + Send + 'static,
+    {
+        let client = self.clone();
+        tokio::spawn(async move { client.until(deadline, call).await })
+            .await
+            .unwrap_or(Err(BridgeError::Timeout))
+    }
+
     /// Shuts the client down, if it connected.
     pub(super) async fn close(&self) -> Result<(), BridgeError> {
         let Some(client) = self.0.lock().await.take() else {
@@ -375,6 +392,29 @@ mod tests {
         Arc::new(client)
     }
 
+    #[tokio::test]
+    async fn 
given_a_detached_call_whose_caller_gave_up_when_its_deadline_passes_should_drop_the_client()
+     {
+        let client = LazyClient::default();
+        *client.0.lock().await = Some(unconnected());
+        let deadline = Instant::now() + Duration::from_millis(50);
+
+        let caller = tokio::time::timeout(
+            Duration::from_millis(10),
+            client.until_detached(deadline, std::future::pending::<Result<u8, 
BridgeError>>()),
+        )
+        .await;
+        assert!(
+            caller.is_err(),
+            "the caller stops waiting before the deadline"
+        );
+        tokio::time::sleep(Duration::from_millis(150)).await;
+        assert!(
+            client.0.lock().await.is_none(),
+            "the stuck client is dropped at its own deadline, not left for the 
next lookup"
+        );
+    }
+
     #[tokio::test]
     async fn 
given_a_call_past_its_deadline_when_given_up_on_should_drop_the_client() {
         let client = LazyClient::default();
diff --git a/gateways/kafka/src/bridge/iggy_bridge/mod.rs 
b/gateways/kafka/src/bridge/iggy_bridge/mod.rs
index 049a63700..18ee64c97 100644
--- a/gateways/kafka/src/bridge/iggy_bridge/mod.rs
+++ b/gateways/kafka/src/bridge/iggy_bridge/mod.rs
@@ -280,15 +280,22 @@ impl std::fmt::Debug for IggyBridge {
 ///
 /// Not the lockstep client: a stuck Produce send would hold every new mTLS 
connection past its
 /// handshake budget, and a burst of handshakes would queue ahead of Produce.
+///
+/// Detached because the caller's `pre_auth_timeout` can be shorter than 
`REQUEST_TIMEOUT`:
+/// cancelling the lookup there would also cancel the deadline that drops a 
stuck client, and
+/// every later lookup would queue behind it.
 #[async_trait]
 impl PrincipalResolver for IggyBridge {
     async fn resolve(&self, username: &str) -> Result<AuthenticatedPrincipal, 
AuthError> {
-        let client = &self.lookup_client;
-        let lookup = async {
-            let connected = client.connected(&self.config).await?;
-            Ok(resolve_principal(connected.as_ref(), username).await)
+        let client = self.lookup_client.clone();
+        let config = self.config.clone();
+        let username = username.to_owned();
+        let lookup = async move {
+            let connected = client.connected(&config).await?;
+            Ok(resolve_principal(connected.as_ref(), &username).await)
         };
-        match client.until(Instant::now() + REQUEST_TIMEOUT, lookup).await {
+        let deadline = Instant::now() + REQUEST_TIMEOUT;
+        match self.lookup_client.until_detached(deadline, lookup).await {
             Ok(resolved) => resolved,
             Err(error) => {
                 warn!(%error, "could not reach Iggy to look up the 
certificate's user");
diff --git a/gateways/kafka/src/tls/identity.rs 
b/gateways/kafka/src/tls/identity.rs
index 877e5061a..e9ede9016 100644
--- a/gateways/kafka/src/tls/identity.rs
+++ b/gateways/kafka/src/tls/identity.rs
@@ -29,7 +29,8 @@ use std::str::FromStr;
 
 use regex::{Captures, Regex};
 use regex_syntax::ast::{
-    self, Assertion, AssertionKind, Ast, ClassPerl, ClassPerlKind, 
ClassSetItem, Flag, Flags, Span,
+    self, Assertion, AssertionKind, Ast, ClassPerl, ClassPerlKind, 
ClassSetBinaryOp,
+    ClassSetBinaryOpKind, ClassSetItem, Flag, Flags, LiteralKind, Span, 
SpecialLiteralKind,
     Visitor,
 };
 use thiserror::Error;
@@ -99,6 +100,21 @@ pub enum JavaIncompatibility {
     PosixClass,
     #[error("word boundaries are not supported")]
     WordBoundary,
+    #[error(
+        "the 'm', 'x' and 'R' flags differ between Rust and Java in which line 
ends and which \
+         whitespace and comments they cover"
+    )]
+    LineOrWhitespaceFlag,
+    #[error("'\\v' is a vertical tab in Rust and any vertical whitespace in 
Java")]
+    VerticalWhitespace,
+    #[error(
+        "Java reads a quantifier after a quantifier as possessive, Rust as a 
nested repetition"
+    )]
+    StackedQuantifier,
+    #[error(
+        "Java reads '--' and '~~' inside a class as literal characters, Rust 
as set operations"
+    )]
+    ClassSetOperation,
 }
 
 /// A replacement that `java.util.regex.Matcher` would throw on when the rule 
is applied.
@@ -455,6 +471,11 @@ impl JavaRewriter {
                 ast::FlagsItemKind::Flag(Flag::Unicode | Flag::SwapGreed) => {
                     return Err((item.span, JavaIncompatibility::UnicodeFlag));
                 }
+                ast::FlagsItemKind::Flag(
+                    flag @ (Flag::MultiLine | Flag::IgnoreWhitespace | 
Flag::CRLF),
+                ) if flags.flag_state(flag) == Some(true) => {
+                    return Err((item.span, 
JavaIncompatibility::LineOrWhitespaceFlag));
+                }
                 _ => {}
             }
         }
@@ -511,6 +532,14 @@ impl Visitor for JavaRewriter {
                 return Err((class.span, JavaIncompatibility::UnicodeClass));
             }
             Ast::Assertion(assertion) => refuse_word_boundary(assertion)?,
+            Ast::Literal(literal)
+                if literal.kind == 
LiteralKind::Special(SpecialLiteralKind::VerticalTab) =>
+            {
+                return Err((literal.span, 
JavaIncompatibility::VerticalWhitespace));
+            }
+            Ast::Repetition(repetition) if matches!(*repetition.ast, 
Ast::Repetition(_)) => {
+                return Err((repetition.op.span, 
JavaIncompatibility::StackedQuantifier));
+            }
             _ => {}
         }
         Ok(())
@@ -532,10 +561,24 @@ impl Visitor for JavaRewriter {
             ClassSetItem::Ascii(class) => {
                 return Err((class.span, JavaIncompatibility::PosixClass));
             }
+            ClassSetItem::Literal(literal)
+                if literal.kind == 
LiteralKind::Special(SpecialLiteralKind::VerticalTab) =>
+            {
+                return Err((literal.span, 
JavaIncompatibility::VerticalWhitespace));
+            }
             _ => {}
         }
         Ok(())
     }
+
+    fn visit_class_set_binary_op_pre(&mut self, op: &ClassSetBinaryOp) -> 
Result<(), Self::Err> {
+        match op.kind {
+            ClassSetBinaryOpKind::Difference | 
ClassSetBinaryOpKind::SymmetricDifference => {
+                Err((op.span, JavaIncompatibility::ClassSetOperation))
+            }
+            ClassSetBinaryOpKind::Intersection => Ok(()),
+        }
+    }
 }
 
 const fn refuse_word_boundary(assertion: &Assertion) -> Result<(), (Span, 
JavaIncompatibility)> {
@@ -1034,9 +1077,25 @@ mod tests {
         }
     }
 
+    /// Asserts each rule list is refused, naming `construct` for `reason`.
+    fn assert_refused(cases: &[(&str, &str, JavaIncompatibility)]) {
+        for &(raw, construct, expected) in cases {
+            match raw.parse::<PrincipalMappingRules>() {
+                Err(MappingRuleError::JavaIncompatible {
+                    construct: found,
+                    reason,
+                    ..
+                }) => {
+                    assert_eq!((found.as_str(), reason), (construct, 
expected), "{raw}");
+                }
+                other => panic!("'{raw}' must be refused, got {other:?}"),
+            }
+        }
+    }
+
     #[test]
     fn given_a_construct_without_java_semantics_should_refuse_the_rule() {
-        for (raw, construct, expected) in [
+        assert_refused(&[
             (
                 "RULE:(?i)^CN=(\u{17F}vc)$/$1/",
                 "i",
@@ -1082,21 +1141,71 @@ mod tests {
                 r"\B",
                 JavaIncompatibility::WordBoundary,
             ),
-        ] {
-            match raw.parse::<PrincipalMappingRules>() {
-                Err(MappingRuleError::JavaIncompatible {
-                    construct: found,
-                    reason,
-                    ..
-                }) => {
-                    assert_eq!((found.as_str(), reason), (construct, 
expected), "{raw}");
-                }
-                other => panic!("'{raw}' must be refused, got {other:?}"),
-            }
-        }
+        ]);
         let case_sensitive: PrincipalMappingRules = "RULE:(?-i)^CN=(.*)$/$1/"
             .parse()
             .expect("(?-i) is Java's default");
         assert_eq!(case_sensitive.map("CN=a").as_deref(), Some("a"));
     }
+
+    #[test]
+    fn given_syntax_rust_and_java_read_differently_should_refuse_the_rule() {
+        assert_refused(&[
+            (
+                "RULE:(?m)^CN=(.*)$/$1/",
+                "m",
+                JavaIncompatibility::LineOrWhitespaceFlag,
+            ),
+            (
+                "RULE:(?x)^CN=(.*)$/$1/",
+                "x",
+                JavaIncompatibility::LineOrWhitespaceFlag,
+            ),
+            (
+                "RULE:^CN=(?R:.*)$/CN/",
+                "R",
+                JavaIncompatibility::LineOrWhitespaceFlag,
+            ),
+            (
+                r"RULE:^CN=([^\v,]+)$/$1/",
+                r"\v",
+                JavaIncompatibility::VerticalWhitespace,
+            ),
+            (
+                r"RULE:^CN=(a\v)$/$1/",
+                r"\v",
+                JavaIncompatibility::VerticalWhitespace,
+            ),
+            (
+                "RULE:^CN=(.*+)$/$1/",
+                "+",
+                JavaIncompatibility::StackedQuantifier,
+            ),
+            (
+                "RULE:^CN=(a{2}+)$/$1/",
+                "+",
+                JavaIncompatibility::StackedQuantifier,
+            ),
+            (
+                "RULE:^CN=([a-z--b]+)$/$1/",
+                "a-z--b",
+                JavaIncompatibility::ClassSetOperation,
+            ),
+            (
+                "RULE:^CN=([a-z~~b]+)$/$1/",
+                "a-z~~b",
+                JavaIncompatibility::ClassSetOperation,
+            ),
+        ]);
+        // Turning a flag off keeps Java's default, so it stays admissible.
+        let explicit_defaults: PrincipalMappingRules = 
"RULE:(?-mx)^CN=([^,]+)$/$1/"
+            .parse()
+            .expect("(?-mx) is Java's default");
+        assert_eq!(explicit_defaults.map("CN=a").as_deref(), Some("a"));
+        // A lazy quantifier and a class intersection mean the same in both.
+        let same_in_both: PrincipalMappingRules = 
"RULE:^CN=([a-z&&[^q]]+?)$/$1/"
+            .parse()
+            .expect("lazy quantifiers and && match Java");
+        assert_eq!(same_in_both.map("CN=abc").as_deref(), Some("abc"));
+    }
 }

Reply via email to