Skip to content

[feat][misc] PIP-478: default file-based TLS factory implementation - #26271

Merged
lhotari merged 25 commits into
apache:masterfrom
lhotari:lh-pip-478-default-tls-factory-v2
Aug 7, 2026
Merged

[feat][misc] PIP-478: default file-based TLS factory implementation#26271
lhotari merged 25 commits into
apache:masterfrom
lhotari:lh-pip-478-default-tls-factory-v2

Conversation

@lhotari

@lhotari lhotari commented Aug 4, 2026

Copy link
Copy Markdown
Member

PIP: #25890 (pip/pip-478.md)

Motivation

This is the second implementation PR for PIP-478, following #26222 which landed the two SPI modules (pulsar-tls-factory-api, pulsar-http-client-api). It adds the default implementation of the PulsarTlsFactory SPI and the JDK-level TLS primitives it is built from.

The PR is additive: the PIP-337 stack (PulsarSslFactory, DefaultPulsarSslFactory, JettySslContextFactory, SecurityUtility, KeyStoreSSLContext) still exists and remains the wired implementation, and the new code is exercised only by its own unit tests. There is no behavioural change to any existing module. Wiring the new factory into the client and the server components, and removing the superseded PIP-337 classes, are later PRs in the series.

Modifications

pulsar-commonorg.apache.pulsar.common.tls.impl, the default factory:

  • FileBasedTlsFactory + FileBasedTlsFactorySettings: the default PulsarTlsFactory, built from PEM or keystore file material. It owns the purpose→source registry, purpose resolution (direct map lookup, then the role's terminal rule — client purposes resolve to the system default, server purposes fail the request), and the reload fan-out to subscribers.
  • TlsMaterialSource / MaterialSource / AuthProvidedMaterialSource / TlsMaterial: per-purpose material load, watch and cache. Value equality on the loaded material suppresses spurious rebuilds when files are touched but unchanged, a failed reload keeps the last good material, and contexts are rebuilt, never mutated in place, so a native OpenSSL engine never observes a torn context.
  • TlsContexts / TlsContextAcquisition / SynthesizedEngineSslContext / TlsSynthesisSpec: SSL context assembly, per-acquisition read of the current context, and SSLParameters companion synthesis.
  • TlsKeyStoreLoader, TlsFactoryProbe, TlsReloadMetrics, package-info.

pulsar-commonorg.apache.pulsar.common.util.tls, the JDK-level primitives:

  • PemReader (PEM parsing), JdkSslContexts (JDK SSLContext assembly), JcaProviders (provider resolution), JcaKeyStores (KeyStore creation through a pinned provider). Extracted from the SecurityUtility grab-bag.
  • Together these implement both provider axes carried by TlsPolicy: jsseProvider pins the SSLContext / KeyManagerFactory / TrustManagerFactory, and jcaProvider pins the KeyStore / CertificateFactory / KeyFactory engines that parse the material. Both are unset by default, which is exactly today's behaviour.
  • KeyStoreHolder gains an optional pinned JCA provider and, on that path only, a random per-instance entry password (a FIPS provider in approved-only mode rejects an empty password-based-KDF password). With no pinned provider its behaviour is unchanged.

pulsar-broker-common:

  • broker/tls/DefaultBrokerTlsFactory + TlsFactorySupport: the default broker-side factory, composed from the existing base tls* fields of ServiceConfiguration. No new configuration keys are added in this PR — those arrive with the migration PR.
  • jetty/tls/JettyTlsFactory: the framework-side Jetty reload driver built on the new factory.

Engine selection. An unset tlsProvider selects Netty's OPENSSL_REFCNT when a netty-tcnative binary is available for the platform, else the JDK engine. This preserves the historical default — the PIP-337 path passes a null provider and SslContextBuilder then picks the native engine — while keeping the new factory off finalization: OpenSslContext frees its native SSL_CTX from a finalize(), which is deprecated for removal (JEP 421) and can already be disabled with --finalization=disabled. Both variants expose real reference counting; this factory owns and deterministically releases every context it builds, so the finalizer only ever masked a bug. An explicitly configured engine literal is honoured verbatim.

tlsProvider and webServiceTlsProvider are now documented as the distinct axes they are — a Netty engine versus a Jetty JSSE (SSLContext) provider — in ServiceConfiguration, ProxyConfiguration, broker.conf, standalone.conf and proxy.conf. No default values change.

Relationship to FIPS and to PIP-489

The main focus of this PR is the default TLS factory above. It does, however, lay the groundwork for a FIPS-compliant TLS transport, which is the subject of PIP-489 (discussion).

PIP-489's own Goals section states that it delivers a FIPS-capable TLS transport by building on PIP-478 rather than defining a parallel TLS configuration path. Because PIP-478 replaces the PIP-337 PulsarSslFactory / PulsarSslConfiguration surface outright in 5.0, parts of the PIP-489 design that are written against that surface — the per-listener .tlsProvider(...) wiring gaps in PulsarChannelInitializer, WebService, the WebSocket proxy and the function worker — become obsolete on master: those call sites do not survive the migration, and the equivalent capability is expressed through TlsPolicy and the factory instead. The remaining PIP-489 scope — the BC/BC-FIPS packaging story, the non-approved algorithms in the crypto and authentication paths, FIPS documentation, and the distribution variant — is unaffected by this PR.

The FIPS-relevant pieces here are:

  • Both provider axes are implemented, including the key/trust-manager algorithm negotiation a FIPS JSSE provider needs (BCJSSE registers KeyManagerFactory.X.509 with a PKIX alias but not the JDK-default SunX509), and pinned in-memory carrier keystores so parsed key material stays inside the pinned module.
  • Pulsar had no BouncyCastle JSSE dependency at all (see pulsar-site#974). bctls is added to the version catalog, and JcaProviders registers BCJSSE on demand when it is the pinned jsseProvider: an operator-registered instance always wins, otherwise it is constructed from the classpath — in FIPS mode bound to the FIPS JCA provider when that is the BouncyCastle artifact present, and in the default mode otherwise. bctls ships no META-INF/services/java.security.Provider entry and its FIPS mode is a constructor argument while it registers under the plain name BCJSSE either way, so neither ServiceLoader discovery nor default construction can produce the right provider — hence the explicit registration.
  • BouncyCastle is no longer mandatory: it is resolved lazily and reported as an Optional, so a deployment without it on the classpath still resolves named providers.

Deliberately not in this PR: bctls is a test dependency only, matching how bcprov is already scoped in pulsar-common (loaded reflectively in main). Which BouncyCastle artifacts land on the Pulsar classpath — selecting the non-FIPS or the FIPS jars via an environment variable in the bin/pulsar script, since the two cannot coexist on one classpath — will come in a later PR, together with the distribution and LICENSE wiring that implies.

Where the rest of PIP-478 goes

Subsequent implementation PRs in this series will cover the migration itself, and with it the TLS-transport essence of PIP-489:

  1. Core migration — the asynchronous v5 client authentication engine and its v4 compatibility bridges, plus wiring the TLS factory into every server component (broker binary listener and web service, proxy, WebSocket proxy, function worker) and the client. This introduces the new configuration surface (tlsFactoryClassName, jsseProvider / jcaProvider and their broker-client counterparts) and makes the new SPI the TLS path in use — which is what turns the engine and provider selection above into something an operator can actually configure end to end.
  2. Authentication plugin migration — the Athenz and SASL plugins onto the v5 asynchronous SPI.
  3. PIP-337 removal — deleting PulsarSslFactory, SecurityUtility, KeyStoreSSLContext and the deprecated CN-based hostname matching, leaving the new SPI as the only TLS path.

Verifying this change

This change added tests and can be verified as follows:

  • FileBasedTlsFactoryTest, TlsMaterialSourceTest, AuthProvidedMaterialFoldTest — context building, purpose resolution, rotation with keep-last-good, and rebuild-not-mutate semantics, exercised with real in-memory TLS handshakes (no broker, no sockets), including the OpenSSL engine path and a reference-count-reaches-zero assertion (there is no finalizer to fall back on with OPENSSL_REFCNT).
  • SslParametersSynthesisTest, TlsReloadMetricsTestSSLParameters companion synthesis and the pulsar.tls.reload metric.
  • JcaProviderPinningTest, JcaProvidersTest, JdkSslContextsTest — the two provider axes: name resolution and ordering, key/trust-manager algorithm negotiation, material loading through a pinned JCA provider, that the Netty builders route their carrier keystore and manager factories through the pins rather than letting Netty build them via the JVM search order, and BCJSSE on-demand registration (including that it supplies SSLContext.TLS and TrustManagerFactory.PKIX but not SunX509).
  • PemReaderTest — PEM preamble parsing across leading-line parities.
  • TlsFactorySupportTest, JettyTlsFactoryTest — the broker-side scaffolding and the Jetty reload driver.
  • ./gradlew :pulsar-common:test :pulsar-broker-common:test — 1261 tests, 0 failures.
  • ./gradlew assemble rat spotlessCheck checkstyleMain checkstyleTest checkBinaryLicense passes.

Does this pull request potentially affect one of the following parts:

If the box was checked, please highlight the changes

  • Dependencies (add or upgrade a dependency) — adds org.bouncycastle:bctls-jdk18on to the version catalog and as a test dependency of pulsar-common. Nothing new ships in the distribution.
  • The public API — adds the org.apache.pulsar.common.tls.impl and org.apache.pulsar.common.util.tls packages, and the org.apache.pulsar.broker.tls / org.apache.pulsar.jetty.tls classes. All additive and unreferenced by existing code in this PR. KeyStoreHolder gains one method and one constructor overload; its existing behaviour is unchanged.
  • The schema
  • The default values of configurations
  • The threading model
  • The binary protocol
  • The REST endpoints
  • The admin CLI options
  • The metrics
  • Anything that affects deployment

Documentation

  • doc-required
  • doc-not-needed — the new implementation is not wired to any user-facing surface yet, and the PIP-478 design document covers the design. The tlsProvider / webServiceTlsProvider comments in the shipped conf/*.conf files are updated here to describe the two provider axes accurately; no website documentation change is needed.
  • doc
  • doc-complete

Matching PR in forked repository

PR in forked repository: lhotari#248 (Personal CI — full matrix green)

This PR was prepared with the assistance of Claude Code (Opus 5); the change was reviewed and is submitted by a human contributor who takes responsibility for it, per the ASF Generative Tooling guidance.

lhotari added 4 commits August 4, 2026 18:17
### Motivation

This is the second implementation PR for
[PIP-478](apache#25890) (design doc), following
apache#26222 which added the SPI modules. It adds the **default implementation** of the
`PulsarTlsFactory` SPI and the JDK-level TLS primitives it is built from.

The PR is purely **additive**: the PIP-337 stack (`PulsarSslFactory`,
`DefaultPulsarSslFactory`, `JettySslContextFactory`, `SecurityUtility`,
`KeyStoreSSLContext`) still exists and remains the wired implementation, and the new
code is exercised only by its own unit tests. Wiring it into the client and the
server components, and removing the superseded PIP-337 classes, are later PRs in the
series.

### Modifications

`pulsar-common` — `org.apache.pulsar.common.tls.impl`, the default factory:

- `FileBasedTlsFactory` + `FileBasedTlsFactorySettings`: the default
  `PulsarTlsFactory`, built from PEM or keystore file material. It owns the
  purpose→source registry, purpose resolution (direct map lookup, then the role's
  terminal rule — client purposes resolve to the system default, server purposes
  fail), and the reload fan-out to subscribers.
- `TlsMaterialSource` / `MaterialSource` / `AuthProvidedMaterialSource` /
  `TlsMaterial`: per-purpose material load, watch and cache. Value equality on the
  loaded material suppresses spurious rebuilds when files are touched but unchanged,
  a failed reload keeps the last good material, and contexts are **rebuilt, never
  mutated in place**, so a native OpenSSL engine never observes a torn context.
- `TlsContexts` / `TlsContextAcquisition` / `SynthesizedEngineSslContext` /
  `TlsSynthesisSpec`: SSL context assembly, per-acquisition read of the current
  context, and `SSLParameters` companion synthesis.
- `TlsKeyStoreLoader`, `TlsFactoryProbe`, `TlsReloadMetrics`, `package-info`.

`pulsar-common` — `org.apache.pulsar.common.util.tls`, the JDK-level primitives:

- `PemReader` (PEM parsing), `JdkSslContexts` (JDK `SSLContext` assembly),
  `JcaProviders` (provider-name resolution; new home of the `BC` constant),
  `JcaKeyStores` (`KeyStore` creation through a pinned provider).
- These implement both provider axes carried by `TlsPolicy`: `jsseProvider` pins the
  `SSLContext` / `KeyManagerFactory` / `TrustManagerFactory` (with the documented
  key/trust-manager algorithm negotiation, since BCJSSE registers `PKIX` but not
  `SunX509`), and `jcaProvider` pins the `KeyStore` / `CertificateFactory` /
  `KeyFactory` engines that parse the material. Both are unset by default, which is
  exactly today's behaviour.

`pulsar-common` — `KeyStoreHolder` gains an optional pinned JCA provider and, on that
path only, a random per-instance entry password (a FIPS provider in approved-only mode
rejects an empty password-based-KDF password). With no pinned provider the store type
and the empty entry password are unchanged, so existing callers keep working.

`pulsar-broker-common`:

- `broker/tls/DefaultBrokerTlsFactory` + `TlsFactorySupport`: the default broker-side
  factory, composed from the **existing** base `tls*` fields of `ServiceConfiguration`.
  No new configuration keys are added here — those arrive with the migration PR.
- `jetty/tls/JettyTlsFactory`: the framework-side Jetty reload driver built on the new
  factory.
- Build wiring: `pulsar-tls-factory-api` and `pulsar-common` become direct `api`
  dependencies, since these classes expose the SPI on the module's public surface.

### Verifications

Unit-tested with real in-memory TLS handshakes and rotation (no broker, no sockets),
including the OpenSSL engine path and provider pinning. Key suites:
`FileBasedTlsFactoryTest`, `TlsMaterialSourceTest`, `SslParametersSynthesisTest`,
`TlsReloadMetricsTest`, `AuthProvidedMaterialFoldTest`, `JcaProviderPinningTest`,
`JcaProvidersTest`, `JdkSslContextsTest`, `TlsFactorySupportTest`,
`JettyTlsFactoryTest`.

`./gradlew :pulsar-common:test :pulsar-broker-common:test` — 1243 tests, 0 failures.

Assisted-by: Claude Opus 5 (Claude Code)
…ider axes

Follow-up to the default file-based TLS factory in the previous commit, applying the
findings of a local multi-model review of that change.

### Motivation

Three themes.

**Engine selection and JEP 421.** The factory mapped an unset `tlsProvider` to the JDK
engine, silently moving deployments off the native engine the PIP-337 path had used (it
passed a null provider, and `SslContextBuilder` then picks the native engine wherever
`netty-tcnative` has a binary). Restoring that default raises a second question: Netty's
`OPENSSL` frees its native `SSL_CTX` from a `finalize()` method, and finalization is
deprecated for removal (JEP 421) and can already be disabled with `--finalization=disabled`,
under which the safety net becomes a permanent native leak. `OPENSSL_REFCNT` is the same
engine without the finalizer, and this factory already owns and deterministically releases
every context it builds.

**Provider pinning did not reach the Netty engine.** `SslContextBuilder.sslContextProvider`
pins the `SSLContext` only; handing Netty raw key/trust material makes it build the carrier
`KeyStore` and the key/trust manager factories through the JVM provider search order
(`SslContext.buildKeyManagerFactory` / `buildTrustManagerFactory` call the no-provider
`getInstance` forms). A `jsseProvider=BCJSSE` + `jcaProvider=BCFIPS` deployment therefore got
its private-key side from SunJSSE — FIPS-shaped rather than FIPS-compliant, which is the
exact property the two axes exist to deliver.

**The two provider axes were not documented as distinct.** `tlsProvider` names a Netty
*engine*; `webServiceTlsProvider` names a *JSSE (SSLContext) provider* for Jetty, which has
no native engine. The similar names invite configuring an engine value where only a JSSE
provider is valid.

### Modifications

Engine selection (`TlsFactorySupport.engineProvider`):

- Unset now selects `SslProvider.OPENSSL_REFCNT` when `OpenSsl.isAvailable()`, else
  `SslProvider.JDK` — restoring the historical default on the finalizer-free variant.
- An explicit engine literal is honored verbatim, so a configured `OPENSSL` still yields
  `SslProvider.OPENSSL` rather than being silently rewritten to a different enum value.
- `FileBasedTlsFactoryTest.closeReleasesEveryBuiltContextToZero` asserts the reference-count
  ledger actually reaches zero, since without a finalizer it is the only thing freeing the
  native context.

Provider pinning (correctness of the two axes):

- `JdkSslContexts` grows `createKeyManagerFactory(PrivateKey, Certificate[], Provider,
  Provider)` and `createTrustManagers(...)`, extracted from the paths that already existed
  for the JDK context, and `TlsContexts` routes the Netty builders through them whenever
  either axis is pinned. With neither pinned the raw-material path is unchanged.
- `KeyStoreHolder` no longer wraps `JcaKeyStores`' actionable "provider does not supply store
  type X" message in a generic "KeyStore creation error".

Configuration documentation — `ServiceConfiguration`, `ProxyConfiguration`, `broker.conf`,
`standalone.conf` and `proxy.conf` now describe the engine-vs-JSSE axis split, what an unset
`tlsProvider` selects, and that Netty engine values are not valid `webServiceTlsProvider`
names. No default value changes.

Rotation and lifecycle:

- `FileBasedTlsFactory.loadInitialInstance` arms the redeliver retry when a context build
  fails *after* the material refresh consumed the rotation's change signal. Without it every
  later poll saw `changed=false` and subscribers stayed on the pre-rotation certificate until
  the files changed again.
- `TlsContextAcquisition`'s synthesizing subscription catches throwables in `publish` (it runs
  inside a `whenComplete` whose future is discarded, so an exception vanished silently) and
  drops deliveries that land after `dispose()`.
- `FileBasedTlsFactory.initialize` requires a non-null `blockingExecutor` instead of falling
  back to caller-thread execution, and closes a previous `TlsReloadMetrics` before replacing
  it so a re-initialize cannot orphan its observable gauge.

Configuration parity:

- `tlsCertRefreshCheckDurationSec <= 0` again means "no background poll", as every v4 call
  site (`WebService`, `PulsarChannelInitializer`, websocket `ProxyServer`) and
  `FileBasedTlsFactorySettings` both document, rather than being replaced by the 60s default.

PEM parsing:

- `PemReader`'s private-key preamble scan consumed two lines per iteration, so whether a key
  file parsed depended on the parity of the number of lines before `-----BEGIN` — an odd count
  (one comment, a blank line, an openssl "Bag Attributes" header) swallowed the marker and
  failed with a misleading "algorithm is not supported". Carried over verbatim from
  `SecurityUtility`; fixed here since this is now the shared PEM primitive.

Documentation accuracy: several javadoc comments described PIP-337 classes as already removed
and referenced a `sslFactoryPlugin` rejection that arrives with a later PR; they now describe
the state at this point in the series. The `SSLParameters` note on `FileBasedTlsFactory` no
longer claims a JDK `SSLContext` carries engine policy (it cannot — those are per-engine
`SSLParameters`), and the Conscrypt note records that `SecurityUtility`'s process-global
CN-tolerant hostname verifier still wins while both stacks coexist. Removed an unused HTTP
rotation-TTL constant and system property whose consumer arrives with the client wiring.

### Verifications

`./gradlew :pulsar-common:test :pulsar-broker-common:test` — 1254 tests, 0 failures.
New regression tests: `PemReaderTest` (verified to fail against the pre-fix scan),
`JcaProviderPinningTest.nettyContextBuildRoutesCarrierKeyStoreThroughThePinnedJcaProvider`
(a provider registering no services must make the Netty build fail loudly, which it could not
before), the refcount-to-zero assertion above, and the engine-selection cases.
`./gradlew assemble rat spotlessCheck checkstyleMain checkstyleTest checkBinaryLicense` passes.

Assisted-by: Claude Opus 5 (Claude Code)
…tered JCA providers

### Motivation

`JcaProviders` is the primitive every TLS policy goes through to resolve the PIP-478
`jsseProvider` / `jcaProvider` names. Two problems with how it handled Bouncy Castle and
provider resolution:

**Bouncy Castle was mandatory, and only by accident.** `public static final Provider
BC_PROVIDER = getProvider()` ran during class initialization and `getProvider()` threw when
Bouncy Castle was absent. So merely resolving a named provider — the one thing production code
actually uses this class for — required `bcprov` on the classpath, in a deployment that may not
want Bouncy Castle at all. Nothing in the new TLS stack needs it: both provider axes are unset
by default and the platform providers (SunJSSE and friends) are used, which are also the better
optimized ones. Bouncy Castle is needed only where it is explicitly asked for.

**Resolution returned a different instance than the operator installed.** It tried `ServiceLoader`
before `Security.getProvider`. `ServiceLoader` can only build a provider through its *no-arg*
constructor, so where both could answer a name, a freshly constructed provider won over the one
the operator registered — losing any provider-level configuration on it, and returning an object
that is not the one in the JVM's provider list. `bcprov` ships a
`META-INF/services/java.security.Provider` entry, so this was reachable for a pinned
`jcaProvider=BC`. (BouncyCastle's *JSSE* provider is unaffected either way: `bctls` ships no
services entry, so `BCJSSE` is only ever resolvable through an operator registration — which is
also the only way its FIPS mode, a constructor argument, can reach Pulsar.)

### Modifications

- `BC_PROVIDER`, the public `getProvider()` and `isBCFIPS()` are replaced by
  `Optional<ResolvedBouncyCastleProvider> bouncyCastleProvider()` and
  `ResolvedBouncyCastleProvider requireBouncyCastleProvider()`, where
  `ResolvedBouncyCastleProvider` is a record carrying the `Provider` together with whether it is
  the FIPS artifact. The flavour is classified once at resolution instead of being re-derived
  from the provider's class name on each query, and absence is a value rather than a class-init
  failure.
  Resolution is lazy, behind a holder class, so loading `JcaProviders` never requires Bouncy
  Castle; it still installs the provider process-wide when it does resolve one.
- `resolveNamedProvider` consults `Security.getProvider(name)` first and `ServiceLoader` second.
  The classpath-discovery path is unchanged for a name nobody registered; only *which instance*
  wins when both can answer changes, and the registered one — the operator's configuration — now
  does.
- `pip/pip-478.md` is updated to describe the new order and no longer documents the shadowing
  hazard as a consequence operators must live with.

Conscrypt is unchanged: it is still resolved during class initialization, reports absence as
`null`, and never fails class loading.

### Verifications

`./gradlew :pulsar-common:test :pulsar-broker-common:test` — 1258 tests, 0 failures.
`JcaProvidersTest.anOperatorRegisteredProviderWinsOverTheServiceLoaderInstance` reproduces the
shape with a `META-INF/services`-declared test provider whose configuration is a constructor
argument, and was confirmed to fail against the previous resolution order; a companion test pins
that the `ServiceLoader` path still resolves a name nobody registered.
`./gradlew assemble rat spotlessCheck checkstyleMain checkstyleTest checkBinaryLicense` passes.

Assisted-by: Claude Opus 5 (Claude Code)
…emand

### Motivation

`jsseProvider=BCJSSE` is the JSSE half of the FIPS configuration the two provider axes exist to
make expressible, but it was the one provider name Pulsar could not resolve on its own.

`bctls` ships no `META-INF/services/java.security.Provider` entry, so `BCJSSE` is invisible to
`ServiceLoader`, and it is absent from `Security` until something registers it. Pinning it
therefore failed unless the operator had already registered the provider themselves. Nor can it
simply be default-constructed: its FIPS mode is a constructor argument fixed at construction
(`new BouncyCastleJsseProvider("fips:BCFIPS")`) while the provider registers under the plain name
`BCJSSE` either way — so a no-arg instance would quietly be the non-FIPS one, which is precisely
the FIPS-shaped-but-not-FIPS outcome the two axes are meant to prevent.

### Modifications

`JcaProviders` resolves `BCJSSE` explicitly, lazily, and reflectively:

- An instance the operator registered wins, whatever mode they built it in.
- Otherwise, when `bctls` is on the classpath, it is constructed and registered here. The mode
  follows the BouncyCastle JCA provider that actually resolved: `fips:<name>` when that is the
  FIPS artifact, the no-arg constructor otherwise. Resolving the JCA provider first also installs
  it, which the FIPS form needs — the JSSE provider looks its crypto provider up by name while
  constructing.
- Otherwise it is absent and pinning `jsseProvider=BCJSSE` still fails loudly, now naming the
  missing jar.

The decision is a `@VisibleForTesting` pure function (`jsseProviderConfig`) so both branches are
testable without bc-fips, which cannot share a classpath with bcprov. FIPS mode is read back
through the provider's own `isFipsMode()`, reflectively, so this module never needs `bctls` to
compile — the same reflective-loading pattern `pulsar-common` already uses for bcprov.

`bctls-jdk18on` is added to the version catalog and as a **test** dependency of `pulsar-common`,
matching how bcprov is scoped there. It is deliberately not a runtime dependency: which
BouncyCastle artifacts land on the classpath is a packaging decision, made separately.

### Verifications

`./gradlew :pulsar-common:test :pulsar-broker-common:test` — 1261 tests, 0 failures.
New tests: the FIPS/non-FIPS branch selection; on-demand registration from the classpath
(asserting the provider is installed and, on this non-FIPS classpath, does not claim FIPS); and
that the registered provider really supplies what the TLS stack asks of it — `SSLContext.TLS`,
`TrustManagerFactory.PKIX`, and `KeyManagerFactory.PKIX` but *not* the JDK-default `SunX509`,
which is what makes the key-manager algorithm negotiation in `JdkSslContexts` load-bearing.
`./gradlew assemble rat spotlessCheck checkstyleMain checkstyleTest checkBinaryLicense` passes.

Assisted-by: Claude Opus 5 (Claude Code)

@david-streamlio david-streamlio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Detailed comments inline. The summary — and the one issue I'd call major — are in the conversation comment on this PR, so I won't repeat them here.

Short version: the change is additive and reads well, and almost all of my concerns are about what the migration PR inherits rather than defects in this one. Nothing blocking on its own.

lhotari added 15 commits August 5, 2026 10:49
… declaration

Review comment: does `TlsReloadMetrics.create(...)` on the mandatory `initialize()` path risk a
`NoClassDefFoundError`, given `opentelemetry-api` is `compileOnly` in `pulsar-common`?

It does not — but the build file said something false, which is what prompted the question.
`pulsar-common` declares `api(project(":pulsar-tls-factory-api"))`, and that module declares
`api(libs.opentelemetry.api)` because `TlsFactoryInitContext` exposes `OpenTelemetry` on the SPI
surface. The classes therefore reach this module's compile *and* runtime classpaths transitively,
and propagate at api scope to every downstream consumer.

That makes the `compileOnly` declaration redundant, and its comment ("Kept compileOnly (matching
pulsar-tls-factory-api)") wrong, since the API module uses `api`. Deleting both is a better answer
than promoting it to `implementation`: it removes the thing that looked like a risk.

Verified: `./gradlew :pulsar-common:dependencies --configuration runtimeClasspath` lists
io.opentelemetry:opentelemetry-api:1.62.0, and :pulsar-common:compileJava succeeds without the line.

Assisted-by: Claude Opus 5 (Claude Code)
… javadoc

Review comment raised three points on `resolveNamedProvider`, all fair:

1. The `ServiceLoader` step constructs every candidate provider as it iterates. That is unavoidable
   here — the name being matched lives on the instance, not on the service descriptor, so
   `ServiceLoader.stream()` would defer construction without being able to answer the query — but it
   was undocumented. Now stated.
2. A provider resolved through `ServiceLoader` is returned without `Security.addProvider`. That is
   deliberate (resolution should not mutate global provider state, and consumers pass the object
   straight to `getInstance`), but given how much the javadoc says about *which instance* answers a
   name, the asymmetry with a later `Security.getProvider` deserved stating. Now stated.
3. An inline comment still described `Security.getProvider` as "step 2". It became step 1 when the
   resolution order was changed to prefer an operator-registered provider; the comment was stale.

Documentation only; no behaviour change.

Assisted-by: Claude Opus 5 (Claude Code)
…r with a named call

Review comment: the `if (JcaProviders.CONSCRYPT_PROVIDER != null) { }` static block reads as a
mistake in a new file and only survives checkstyle by luck; the "DO NOT EDIT" it carries no longer
protects anything cryptic.

Correct — it was copied verbatim from `JettySslContextFactory`. Replaced with
`JcaProviders.ensureConscryptRegistered()`, a named no-op whose javadoc states what the call is for
(installing Conscrypt process-wide before Jetty resolves a JSSE provider by name), and dropped the
"DO NOT EDIT".

Assisted-by: Claude Opus 5 (Claude Code)
…module-visible

Review comment: the `META-INF/services` entry registers this fixture for the whole `pulsar-common`
test source set; could it be scoped, or installed via `Security.addProvider` in a `@BeforeClass`?

The visibility is exactly as described, but both alternatives defeat the fixture's purpose. Being
discoverable through `ServiceLoader` IS the property under test: the assertion is that when both
resolution paths can answer a name, the operator-registered instance wins over the one
`ServiceLoader` builds through the no-arg constructor. Registering it programmatically removes the
condition under test, and a dedicated source set is a lot of Gradle for an inert fixture.

The blast radius is bounded because the provider registers no services at all — it can never satisfy
a `getInstance()` call, and is visible only to code enumerating `ServiceLoader<Provider>`.

Recorded that reasoning on the fixture so it does not have to be rediscovered. No behaviour change.

Assisted-by: Claude Opus 5 (Claude Code)
… policies

Review comment: `tlsHostnameVerificationEnabled` is the broker's outbound client setting, but
`serverPolicy()` applies it to the `BROKER`/`PROXY`/`WEB` policies — all server-role purposes.

Correct. The setting is documented on `ServiceConfiguration` as "whether the hostname is validated
when the broker creates a TLS connection with other brokers", and only the client context builders
read the flag — `buildNettyServerContext` ignores it. It is inert today, but it leaves a server
policy carrying a client-only flag, and a future consumer reading it off the policy would enable
endpoint identification on a server engine, i.e. verify the CLIENT's hostname.

The suggestion was to leave it unset. `TlsPolicy` has no tri-state for it, and the builder defaults
it to true (secure-by-default for clients) — so omitting the setter would have left every server
policy claiming verification is on, which is worse than the reported problem. Pinned to false
explicitly instead, which is the inert value for a server engine, with the reasoning recorded.

`brokerClientPolicy()` keeps mapping the setting, which is where it belongs. No behaviour change:
nothing reads the flag for a server purpose.

Assisted-by: Claude Opus 5 (Claude Code)
Review comment: the `WEB` purpose is composed from `serverPolicy(conf)`, which reads `tlsProvider`,
`tlsProtocols` and `tlsCiphers` — but the web listener has its own keys, and
`webServiceTlsProvider` defaults to a non-blank `Conscrypt`. Wired as-is, the web listener would
take the binary listener's provider and lists.

`WEB` now has its own policy: `webServiceTlsProvider`, `webServiceTlsProtocols` and
`webServiceTlsCiphers` take precedence, falling back to `tlsProvider` / `tlsProtocols` /
`tlsCiphers` when unset. Material (PEM or keystore) and the insecure flag stay shared, as today.

Two clarifications on the reported consequences, which do not change the fix but do change what it
is fixing. Both describe master rather than a regression this series would introduce:

- `WebService.java:178-180` already passes `config.getTlsCiphers(), config.getTlsProtocols()` to
  `JettySslContextFactory` — the broker web listener has never read `webServiceTlsProtocols` or
  `webServiceTlsCiphers`. Honouring them here is new behaviour, and arguably the fix for a
  long-standing gap rather than the avoidance of a new one.
- `webServiceTlsProvider` *is* read today (same call site) but only reaches Jetty's `setProvider()`;
  the `SSLContext` Jetty serves with comes from `DefaultPulsarSslFactory` via `buildSslConfiguration`,
  which passes no provider, and the PIP-337 `getSslContext()` override shadows whatever Jetty would
  have built. So the Conscrypt default has been inert on the broker web path since 4.0. Honouring it
  now means a deployment on the default will genuinely get Conscrypt for the web listener; Conscrypt
  ships in the server distribution, and where its native library cannot load the pin fails loudly at
  startup rather than being silently ignored.

`pip/pip-478.md` claimed no code reads `webServiceTlsProvider`; that was true of the proxy but not
the broker. Corrected, and extended to state the new precedence.

Tests cover the precedence, the fallback, that the binary-listener policy never reads the web keys,
that an engine literal in the web provider selects no JSSE provider, and that server-role policies
do not carry the outbound hostname-verification flag.

Assisted-by: Claude Opus 5 (Claude Code)
…te PIP-337 parity

Review comment: `requireTrustedClientCert` is factory-wide and so applies identically to `BROKER`,
`PROXY` and `WEB` — which matches PIP-337, but since it is one client-auth knob for three listeners
with historically separate wiring, it is worth stating that this is intentional parity rather than a
simplification.

Agreed. The javadoc said the flag applies to every server purpose but not why. It now says that v4
keyed all three listeners off the same `tlsRequireTrustedClientCertOnConnect`,
`JettySslContextFactory` included, so per-listener client-auth was never configurable. Also spelled
the property name in full, which was abbreviated.

Documentation only.

Assisted-by: Claude Opus 5 (Claude Code)
…dle's guard

Review comment: `SubscriptionHandle` uses a plain volatile check-then-set while `OneShotHandle`
deliberately uses an `AtomicBoolean` with a comment calling that exact pattern a bug. It happens to
be safe here, but only because `removeSubscription` is guarded by `subscriptions.remove()` returning
true — the flag is not doing the work.

That reading is right, and the asymmetry is the problem: two handles a few lines apart with opposite
patterns, one carrying a comment that condemns the other, tells the next reader that one of them is
wrong. Switched to `AtomicBoolean.compareAndSet` and recorded that `remove()` is the actual
linearization point, so the guarantee does not depend on which guard you happen to read.

No behaviour change — disposal was already idempotent.

Assisted-by: Claude Opus 5 (Claude Code)
…) in the insecure-mode warn set

Review comment: `INSECURE_WARNED` is a static set that is never cleared and holds `TlsPolicy`
objects, which carry `keyStorePassword` / `trustStorePassword` — so it pins plaintext passwords for
the JVM lifetime, once per distinct insecure policy the process ever builds.

Correct, and the password retention matters more than the unboundedness: only policies with
`allowInsecureConnection=true` enter, so a broker with a fixed policy set adds one or two entries,
but a long-lived process constructing many client policies (proxy, function workers, test suites)
accumulates both.

Now keyed by `policy.hashCode()` rather than the policy, so nothing is retained beyond an int, and
capped. Past the cap it warns every time rather than silently going quiet — losing the signal that
peer verification is disabled would be a worse failure than a repeated warning. A hash collision
costs at most one suppressed warning.

Assisted-by: Claude Opus 5 (Claude Code)
…odification time alone

Review comment: change detection is mtime-only, so on a filesystem with 1-second mtime granularity a
certificate replaced twice within the same second — or restored with a preserved mtime — is missed
until the next real change. Not a regression (v4's `FileModifiedTimeUpdater` had the same weakness),
but cheap to improve while the code is being written.

Taken, and extended slightly. `Files.readAttributes(BasicFileAttributes.class)` returns modification
time, size and `fileKey` in the same single stat the poll was already doing, so the snapshot now
carries all three:

- size catches a replacement of different length;
- `fileKey` (the inode, where the filesystem supplies one) catches the write-to-temp-and-rename
  pattern, which is how most certificate tooling rotates and which same-length rotations would
  otherwise defeat — size alone would miss a renewed certificate with the same key size.

A null `fileKey` (filesystems that supply none) is part of the snapshot value, so those deployments
degrade to mtime-plus-size rather than failing.

Two keep-last-good tests had to be updated, and the reason is the point of the change: both faked
"nothing changed" by restoring only the mtime of a file whose contents they had replaced. The new
snapshot sees through that, which is exactly the weakness reported. They now restore the content as
well, rewriting the same path so the inode is preserved.

Assisted-by: Claude Opus 5 (Claude Code)
…not the framework scheduler

Review comment: `pollSafely` runs on `context.scheduler()`, and `RegisteredSource.poll` synchronously
stats files, parses PEM/keystores and rebuilds contexts — all under the source monitor. That is
exactly the work `initialize()` refuses to accept a null `blockingExecutor` for, on exactly the
stated grounds. With several purposes and material on slow or NFS-backed storage it occupies a shared
framework scheduler thread for the duration.

Correct, and the inconsistency was with our own rationale a few lines up. The scheduler now only
triggers the poll; the work runs on the blocking executor.

Dispatching gives up `scheduleWithFixedDelay`'s guarantee that one run completes before the next is
scheduled, so an in-flight flag stands in for it: a poll slower than the interval skips ticks rather
than queueing them behind itself. A rejected dispatch (executor shutting down, queue full) clears the
flag so a later tick retries rather than wedging the poll permanently.

Assisted-by: Claude Opus 5 (Claude Code)
…of deadlocking

Review comment: the javadoc is explicit that consumer callbacks run under the source monitor and
that a callback must not `createInstance` for the same purpose and block — but the constraint is
documented and unenforceable, and the consumers are written in later PRs by people who will not
necessarily read this class.

Fair, and the "make it loud rather than a hang" half of the suggestion is the one taken. An
acquisition that re-enters for the same purpose now fails the returned future with an
`IllegalStateException` naming the problem and pointing at the fix, instead of blocking forever.
Failing the future (rather than throwing) keeps the SPI's never-throw-synchronously rule.

Only same-purpose re-entry is rejected, because that is the case that cannot succeed; a callback
acquiring a different purpose takes a different monitor and is merely inadvisable.

On the other half — snapshotting and invoking callbacks outside the lock — the monitor is doing a
second job beyond rebuild-and-publish atomicity: it makes deliveries serial per subscription, which
the framework's Jetty coordinator relies on for its generation ordering, and `loadInitialInstance`
fans out under the same lock when an acquisition observes a rotation first. Moving callbacks out
needs per-subscription serialization machinery, which is not something to introduce in the PR that
has no consumers yet. The related Jetty finding is addressed separately by taking the reload off the
delivery thread, which removes the framework's own violation of the contract.

Assisted-by: Claude Opus 5 (Claude Code)
…d close the start race

Addresses three related review comments on `JettyTlsFactory`.

**The coordinator collapsed to a synchronous reload under the source monitor.** The design composes
the companion request and never joins it, but for the DEFAULT factory that future is already
complete: `FileBasedTlsFactory.isSupported()` covers only `SslContext` and `SSLContext`, so
`createInstance(purpose, SSLParameters.class)` returns `completedFuture(Optional.empty())` and the
`whenComplete` runs INLINE — on a delivery thread that is inside the factory's `synchronized`
`deliverToSubscribers`. The whole Jetty reload therefore ran with the source monitor held. The
javadoc's "never joined on the delivery thread" was literally true and beside the point: it
established a source -> coordinator -> Jetty lock chain, and made the framework's own first consumer
violate the "cheap non-blocking store" callback contract that `deliverToSubscribers` states.

Both builders now take the reload `Executor` and `onDelivery` uses `whenCompleteAsync`, so `publish`
never runs on the delivery thread. No callers exist yet, so the signature change is free. The first
delivery — which fires synchronously during subscribe and only sets the context — stays inline.
(One qualification: only a delivery, i.e. changed material or a pending redeliver, took that path;
a steady-state poll with no rotation did not.)

**TOCTOU between `isStarted()` and the apply.** A connector starting in that window took its
`load()`-time snapshot before the direct setters landed, so the rotated context was silently not
served: no exception, the delivery counted a successful reload, and the listener stayed on
pre-rotation material until the NEXT material change — which at a 90-day renewal cadence can outlive
the certificate. `publish` now always goes through `target.reload(...)`, which takes the same
internal lock as Jetty's `doStart()`/`load()`, so the check and the apply stop being separable. The
existing pre-start rotation test confirms `reload()` on a not-yet-started factory is benign with the
pinned Jetty version.

**`join()` was safe only by convention.** The default factory dispatches acquisitions to the blocking
executor, so calling these synchronous builders FROM that executor waits on work queued behind
itself — plausible for the websocket proxy and the functions worker, which are exactly the components
`TlsFactorySupport.initContext` has a dedicated overload for. The constraint is now stated on both
public builders. The joins are also centralized so `CompletionException` is unwrapped: a
misconfigured purpose surfaces as "No TLS material configured for server purpose WEB" rather than
wrapped in a `CompletionException`.

Tests: a new test asserts the reload runs on the supplied executor and not on the delivery thread;
existing tests pass a same-thread executor so their synchronous assertions stay deterministic.

Assisted-by: Claude Opus 5 (Claude Code)
…ightenings

Review comment: `TlsMaterialSource` now fails the load for an empty keystore truststore, a keystore
with no usable key entry, and a PEM certificate without its private key. These are startup failures
on upgrade, and operators should not discover them from a stack trace — the release notes should
call all three out explicitly.

Agreed, and the enumeration was exactly right. Added to the upgrade section of the PIP so the text
exists before the migration PR needs it, including why the empty-truststore case is the
security-relevant one (an empty trust list was indistinguishable downstream from "no truststore
configured", so the deployment trusted every public CA while believing it had pinned a private one)
and that the PEM trust axis is deliberately left untightened for 4.x parity.

Documentation only.

Assisted-by: Claude Opus 5 (Claude Code)
…identity

Review comment: rotation suppression rests on `TlsMaterial`'s generated `equals`, which delegates to
`PrivateKey.equals`. That is encoding equality for SunRsaSign and for BC/BCFIPS so it works, but it
is an implicit contract on a provider-supplied object — and the whole point of the `jcaProvider` axis
is that the provider is swappable. A provider whose keys used identity semantics would make every
refresh look changed.

Fair, and worth pinning with a test. Two bounds are worth recording alongside it: for the file-backed
source the file-stamp baseline short-circuits before equality is consulted, so identity-equals keys
would only cause churn on a touch-without-content change; and the failure mode is rebuild churn plus
a misleading `pulsar.tls.reload` count, not incorrect TLS.

The test asserts against `BC` rather than `BCFIPS`, because the two cannot share a classpath
(mismatched jar signers) — which is what the separate bcfips test module exists for. They share the
encoding-equality implementation, so the property under test is the same.

Assisted-by: Claude Opus 5 (Claude Code)
@lhotari

lhotari commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Thanks — this is a careful review, and it found two things that two prior multi-model review passes on this branch missed: the coordinator's whenComplete running inline under the source monitor (the default factory never returns an incomplete SSLParameters future), and the isStarted() TOCTOU that drops a rotation silently.

Every comment has an inline reply. 17 of the 19 are addressed in 15 commits, one per comment where that made sense.

Behaviour changes

  • WEB purpose honours webServiceTlsProvider / webServiceTlsProtocols / webServiceTlsCiphers, falling back to the binary-listener keys — a3d135b
  • Jetty reload moved off the delivery thread onto a supplied executor — a4832cd
  • Jetty apply became an unconditional reload(...), closing the start race — a4832cd
  • Synchronous builders document the blocking-executor constraint and unwrap CompletionExceptiona4832cd
  • Material poll runs on the blocking executor, not the framework scheduler — 49ef9eb
  • Re-entrant acquisition fails loudly instead of deadlocking — 373d462
  • Hostname verification pinned off on server-role policies — 984fd9d
  • Rotation detection uses size and file identity, not mtime alone — 065b304
  • Insecure-mode warn set no longer retains policies (and their passwords) — 0344337

Hygiene, docs and tests

  • Redundant compileOnly opentelemetry-api and its incorrect comment deleted — 21a9afb
  • JcaProviders resolution javadoc corrected and completed — ccc2240
  • Named class-initialization call replaces the empty if7316728
  • SubscriptionHandle.dispose matches OneShotHandle's guard — 01c848e
  • Factory-wide client-auth documented as deliberate PIP-337 parity — 0aa644b
  • The three fail-closed material tightenings documented for the upgrade notes — 9d3ea3c
  • BouncyCastle key value-equality pinned by a test — a9e50fe
  • Test-provider visibility rationale recorded — 50d9109

Not code changes here, deliberately

  • The auth-overlay re-parse: an auth-data identity check would miss plugin-internal rotation, so the workable form is mtime-stats of the plugin's file paths. Better landed in the PR that decides the acquisition pattern, where it can be measured rather than guessed.
  • The Conscrypt global-verifier constraint: getting a tracking issue linked from PIP-478, as you suggested.

One I am pushing back on

opentelemetry-api is on the runtime classpath transitively via pulsar-tls-factory-api's api declaration, so the NoClassDefFoundError cannot occur. But the compileOnly line that prompted the question was redundant and its comment was wrong, so both are deleted — details in that thread.

Two points on the webServiceTls* comment describe master's existing behaviour rather than a regression this series introduces; evidence in that thread, along with an error of ours in the PIP text that your comment flushed out.

Verification. 1270 tests, 0 failures across pulsar-common and pulsar-broker-common, plus assemble rat spotlessCheck checkstyleMain checkstyleTest checkBinaryLicense. Full CI matrix running.

One ask: the review body mentions a summary and "the one issue I'd call major" in a conversation comment, but I don't see one on the PR — could you re-post it? I don't want to answer around it.

@david-streamlio david-streamlio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough turnaround — I re-read the delta (15 files, +550/-86) against the prior base and most of it lands cleanly. Verified as correct: the poll dispatch with the pollInFlight flag, the FileStamp record (mtime + size + fileKey() from one stat, which catches the write-to-temp-and-rename rotation that cert-manager and friends use), the AtomicBoolean symmetry on SubscriptionHandle.disposed, bounding INSECURE_WARNED by hashCode so TlsPolicy objects (and their plaintext passwords) aren't pinned for the JVM lifetime, webPolicy() restoring WEB precedence for webServiceTls*, enableHostnameVerification(false) pinned on the server roles, and the unconditional target.reload() — I decompiled Jetty 12.1.10 and confirmed reload() and doStart() do share the same AutoLock, so the TOCTOU fix is sound.

Two of the fixes don't do what they say, though, and in both cases the accompanying test cannot fail. I mutation-tested both rather than argue from reading:

  1. (major) the re-entrancy guard is evaluated only after the hop to blockingExecutor, so it's a no-op under any real executor — the original deadlock is unchanged. Running the shipped test verbatim against a real single-threaded executor gives TimeoutException, not the IllegalStateException it asserts.
  2. (moderate) reverting the whenCompleteAsync(..., reloadExecutor) fix back to inline whenComplete leaves JettyTlsFactoryTest at 17/17 green, rotationReloadRunsOnTheSuppliedExecutorNotTheDeliveryThread included.

Plus three minor items. Details, evidence and suggested fixes inline.

lhotari added 5 commits August 6, 2026 12:23
…ead, before dispatch

The guard added in the previous round did not work. It was called inside the `runAsync` lambda, so it
only ever ran after the hop to `blockingExecutor` — and `Thread.holdsLock` is per-thread:

- on a single-threaded blocking executor the nested task queues behind the very callback waiting for
  it, so the check never runs at all and the callback blocks forever;
- on a multi-threaded one the task starts on a different thread, sees `holdsLock == false`, passes the
  guard, and blocks on `synchronized (source)` one frame later. Same deadlock.

It fired in exactly one configuration — a direct executor — and that is the configuration in which the
deadlock cannot happen at all, because monitors are reentrant and the nested acquisition simply
succeeds inline. So it converted the one working case into a failure while leaving both real deadlocks
untouched. The shipped test passed only because the test helper initializes with `Runnable::run`.

The check now happens on the caller's thread, before dispatch, in both acquisition paths.
`resolve(purpose)` moves with it and is wrapped: it can throw for a null purpose or an unconfigured
server purpose, and the SPI forbids throwing on the calling thread, so that failure becomes a failed
future too. `rejectReentrantAcquisition` becomes the `reentrantAcquisition(purpose)` exception factory
the callers use to build that future.

The javadoc gains two things it was missing: that the detection must happen pre-dispatch and why, and
that a callback acquiring a *different* purpose avoids this deadlock but can still starve on a
single-threaded executor — the previous wording called that "merely inadvisable", which understated
it. It also now says that a non-blocking same-purpose acquisition from a callback is rejected as well,
since the two cannot be distinguished at the point of the call.

The test now uses a real single-threaded blocking executor — the configuration that actually deadlocks
— with bounded waits so a regression fails instead of hanging the suite, and covers a rotation
delivery as well as the initial one. Verified by mutation: with the pre-dispatch guard removed the
test fails; previously the equivalent test passed against the broken code.

Assisted-by: Claude Opus 5 (Claude Code)
The test guarding the source -> coordinator -> Jetty lock-chain fix could not fail against the
implementation it protects. Both assertions were tautologies:

- it submitted a NEW task to the reload executor after the reload had already completed and asserted
  that task's thread name, which is the executor's own naming and says nothing about where `publish`
  ran;
- it asserted the delivery thread was no longer alive, which `join(10s)` had already guaranteed and
  which holds under inline execution too.

Reverting `whenCompleteAsync(..., reloadExecutor)` to inline `whenComplete` left the whole class green.

Replaced with a test that discriminates dispatch from inline. A holding executor parks submitted
tasks; the companion resolves synchronously (no `deferCompanions()`), which is precisely the default
factory's behaviour, since `SSLParameters` is an unsupported class and `createInstance` returns an
already-completed future — the case where an inline `whenComplete` would run the whole Jetty reload on
the delivery thread. After `deliver(...)` returns, the assertion is that the rotation has NOT been
applied and exactly one task is parked; releasing it then applies the rotation.

Gating the companion future instead would not have worked: registering an inline `whenComplete` on an
incomplete future also returns promptly, so the delivery thread comes back either way and the
assertions would pass under the mutation.

Verified by mutation: with `whenCompleteAsync` reverted to `whenComplete` this test fails.

Assisted-by: Claude Opus 5 (Claude Code)
…ck to inline execution

`reloadExecutor == null ? Runnable::run : reloadExecutor` meant a null argument produced exactly the
inline behaviour the parameter had just been introduced to remove — the Jetty reload back on the
delivery thread, under the source monitor.

Now required. Validated at both public builder entry points rather than in the coordinator: the
native-factory path never constructs a coordinator, so a null would have sailed through and only
surfaced later if the custom factory stopped supplying its native instance. Validating at the entry
also fails before any factory acquisition side effects.

A caller that genuinely wants same-thread execution passes `Runnable::run`, which states the intent
and is greppable; a silent null is neither. There are no production call sites yet — the two
declarations are the only non-test references on this branch — so requiring it now costs nothing and
avoids shipping inline as the effective default.

Note this makes the argument mandatory, not asynchronous: `Runnable::run` is non-null and still
inline. What protects the actual invariant is the dispatch test, which now fails if the reload runs
inline.

Assisted-by: Claude Opus 5 (Claude Code)
…tty builders

`reloadExecutor` was added to both public builders without a matching `@param`, which is exactly
where a caller looks to answer "what do I pass, and can it be null?". Both now document it, including
that it must not be null and must not execute inline on the delivery thread, and that
`Runnable::run` is the explicit opt-in for same-thread execution.

Assisted-by: Claude Opus 5 (Claude Code)
…to its test

A rebase artifact left this Javadoc block sitting above the reload-executor test, which has its own
Javadoc immediately below it; the block describes `preStartRotationRefreshesCompanionBeforeStart`,
which had none. Moved onto the test it documents.

Assisted-by: Claude Opus 5 (Claude Code)
@lhotari

lhotari commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Thanks — this one landed harder than the last. Both mutation results reproduce, and you're right about the cause in each case: the re-entrancy guard fired only under a direct executor, which is the one configuration where the deadlock cannot occur, and the reload-executor test asserted the executor's own thread naming rather than where publish ran.

All six are addressed in 5 commits. The two substantive ones:

  • 323e0cc — the re-entrancy check moves to the caller thread, pre-dispatch, in both acquisition paths, with resolve(purpose) wrapped so its failure stays a failed future rather than a synchronous throw (the SPI forbids that, so the suggested snippet needed one amendment). The test now uses a real single-threaded executor with bounded waits and covers a rotation as well as the initial delivery.
  • afa9268 — the dispatch test now gates the reload executor with a synchronously-completing companion. Gating the companion future, as suggested, would not have failed under your mutation: an inline whenComplete on an incomplete future also returns promptly.

Plus 82019a6 (executor required, validated at both entry points so the native-factory path can't bypass it), c7a8542 (@param) and 4487e52 (Javadoc move).

Every changed test in this round was mutation-tested against the unfixed code before I claimed it works — that was the gap in my previous round, and it's a fair correction to have had made twice.

1269 tests, 0 failures locally, plus the full lint/license gate. I've resolved the threads from the first review; these six are the open ones.

@lhotari
lhotari requested a review from david-streamlio August 6, 2026 16:03
…e broker-client policy

tlsHostnameVerificationEnabled is the broker's outbound setting, so it is mapped onto the
client-role BROKER_CLIENT policy and pinned off on the server-role ones (BROKER / PROXY /
WEB), where the flag would mean verifying the connecting client's hostname instead.

Only the negative half of that was asserted, which reads as if the configuration were being
ignored. Assert the positive half too — in both positions — by making brokerClientPolicy
package-private for the test, and name the honoring site in the comment at the pin.

Both directions are mutation-verified: mapping the flag onto serverPolicy, or dropping it
from brokerClientPolicy, fails the test.

Assisted-by: Claude Code (Opus 5)

@david-streamlio david-streamlio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All six items from the last round are addressed, and the two substantive ones are now backed by tests that can actually fail. I re-ran the mutations myself at 4487e52118 rather than take the commit messages for it:

Pre-dispatch guard (323e0cca8c) — removing the Thread.holdsLock(source) check from both acquisition entry points:

FileBasedTlsFactoryTest > reentrantAcquisitionFromAReloadCallbackFailsLoudlyInsteadOfDeadlocking FAILED
    java.util.concurrent.TimeoutException
        at FileBasedTlsFactoryTest.java:751

Worth noting the bounded wait does what you said it would: the regression surfaces as a failure in 46s, not as a hung suite.

Reload dispatch (afa92685af) — reverting whenCompleteAsync to inline whenComplete:

JettyTlsFactoryTest > rotationReloadRunsOnTheSuppliedExecutorNotTheDeliveryThread FAILED
    expected ... to refer to the same object
        at JettyTlsFactoryTest.java:646

That's the "must be dispatched, not run inline on the delivery thread" assertion, i.e. the one that matters. Clean run at HEAD: BUILD SUCCESSFUL, both classes green.

Corrections to my review, which you were right about

Four things in my comments were wrong, and three of them would have made the code worse if you'd applied them verbatim:

  1. resolve(purpose) is not "just a registry lookup." I said moving it to the caller thread was free. It isn't — FileBasedTlsFactory:411-422 does Objects.requireNonNull and throws TlsMaterialUnavailableException for an unconfigured server purpose, and the SPI javadoc's "Never throws synchronously" clause covers argument validation explicitly. My snippet would have violated the SPI contract on the calling thread. Your try/catch is the right shape.

  2. Gating the companion future doesn't discriminate. You're right and I should have caught this: registering an inline whenComplete on an incomplete future also returns promptly, so the delivery thread comes back under both variants and my proposed assertions would have passed under my own mutation. Gating the reloadExecutor with the companion resolving synchronously is the correct discriminator — and it's the real default-factory path, since SSLParameters is unsupported.

  3. The line-657 javadoc is on RegisteredSource.deliverToSubscribers, not SubscriptionHandle. Confirmed.

  4. "Every production caller would pass null" — wrong, and my own comment contradicted it a paragraph later. There are no production callers at all, which is exactly what makes tightening it free now.

Your sharpening on (1) is the part I'd want in the commit log, and it's there: under a direct executor the deadlock is unreachable anyway because monitors are reentrant, so the old guard fired only where nothing could hang and turned a working case into a failure. That's a better characterization than my three-way split.

Also agree on the requireNonNull placement — the native-factory path returns at line 175-178 before the coordinator is constructed, so my suggested placement would have let a null through there. And the point that non-null ≠ asynchronous is the right one: it's the dispatch test, not the null check, that holds the invariant.

Two non-blocking notes

  • The Conscrypt follow-up issue doesn't appear to be filed yet. You said it would be opened and linked from PIP-478; I don't see a link in pip/pip-478.md at HEAD. Not a blocker for this PR — the substance is recorded in the review thread — but it's the one commitment here that lives outside the code, and it gates a different PR (the one turning SAN-only verification on by default). Worth filing before this merges so the link exists.

  • @nodece's suggestion on DefaultBrokerTlsFactory:168 looks already handled by design rather than missed: that's the server-role policy, and brokerClientPolicy() does map conf.isTlsHostnameVerificationEnabled() at line 196. tlsHostnameVerificationEnabled is documented as the broker's outbound setting, so applying it to a server policy would mean verifying the client's hostname. The comment at 158-166 explains this, but since it reads as unaddressed, an explicit reply pointing at line 196 would probably close it out.

CI is red on infrastructure, not code — Preconditions died on Failed to resolve action download info. Error: Service Unavailable and everything downstream was skipped. Needs a re-run.

Approving. Thorough responses throughout this round — particularly catching that my own remediation for the tautological test was itself untestable.

lhotari added a commit to lhotari/pulsar that referenced this pull request Aug 6, 2026
TLS hostname verification is on by default in 5.0 and CN-based matching is removed, so a test
server certificate that names its host only in the CN is now rejected with "No subject
alternative DNS name matching localhost found".

Two fixtures still had that shape:

- tests/certificate-authority/ec/server.cert.pem (and the matching JKS) carried
  DNS:pulsar, DNS:pulsar.default, IP:127.0.0.1, IP:192.168.1.2 — no localhost — which failed
  TlsWithECCertificateFileTest in CI (Broker Group 2).
- tests/certificate-authority/jks/broker.keystore.jks carried CN=localhost and no SAN at all.

Regenerate both with a localhost SAN, and record in generate_keystore.sh why the broker
keystore needs one while the client and proxy keystores deliberately do not: only the broker
cert is presented as a TLS server certificate, and only server certificates are
hostname-verified.

These fixtures were regenerated in the branch behind apache#26271 but left out of that
PR, since nothing there wires the factory in and the old certificates still passed. This is
the change that makes hostname verification live, so they belong here.

Assisted-by: Claude Code (Opus 5)
@lhotari
lhotari merged commit bab5c77 into apache:master Aug 7, 2026
43 of 44 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Scalable Topics / Pulsar 5.0 Aug 7, 2026
lhotari added a commit to lhotari/pulsar that referenced this pull request Aug 7, 2026
… + server-side TLS factory integration (core migration)

This is the core of PIP-478: migrate the client and server to the asynchronous v5
authentication interface and the pluggable server-side TLS factory. The legacy PIP-337
PulsarSslFactory and SecurityUtility code paths are replaced throughout client, admin,
broker, proxy, websocket and functions-worker, with secure-by-default, SAN-only hostname
matching.

Rebased onto the reworked default TLS factory (apache#26271) and current master.

Assisted-by: Claude Code (Opus 5)
lhotari added a commit to lhotari/pulsar that referenced this pull request Aug 7, 2026
…tory API

The default TLS factory was reworked during the review of apache#26271, so two of the
APIs this migration calls have moved:

- JettyTlsFactory's two public builders now require an Executor for the rotation reload, so
  that it never runs inline on the factory's delivery thread. Each of the five call sites
  passes the executor that component already owns for TLS work: the broker's shared executor
  (WebService), the websocket / functions-worker scheduled executor, the proxy web server's
  dedicated TLS refresh executor, and the admin handler's SSL refresher.
- TlsContextAcquisition's HTTP rotation connection-TTL constant and system property were
  dropped from apache#26271 as unused; their consumers (HttpClient and the v5
  FrameworkHttpClientFactory) arrive here, so they are restored with this change.

Assisted-by: Claude Code (Opus 5)
lhotari added a commit to lhotari/pulsar that referenced this pull request Aug 7, 2026
TLS hostname verification is on by default in 5.0 and CN-based matching is removed, so a test
server certificate that names its host only in the CN is now rejected with "No subject
alternative DNS name matching localhost found".

Two fixtures still had that shape:

- tests/certificate-authority/ec/server.cert.pem (and the matching JKS) carried
  DNS:pulsar, DNS:pulsar.default, IP:127.0.0.1, IP:192.168.1.2 — no localhost — which failed
  TlsWithECCertificateFileTest in CI (Broker Group 2).
- tests/certificate-authority/jks/broker.keystore.jks carried CN=localhost and no SAN at all.

Regenerate both with a localhost SAN, and record in generate_keystore.sh why the broker
keystore needs one while the client and proxy keystores deliberately do not: only the broker
cert is presented as a TLS server certificate, and only server certificates are
hostname-verified.

These fixtures were regenerated in the branch behind apache#26271 but left out of that
PR, since nothing there wires the factory in and the old certificates still passed. This is
the change that makes hostname verification live, so they belong here.

Assisted-by: Claude Code (Opus 5)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants