This is an automated email from the ASF dual-hosted git repository.
raboof pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-grpc.git
The following commit(s) were added to refs/heads/main by this push:
new 8c11091f Percent-decode grpc-message on the pekko-http client (#869)
8c11091f is described below
commit 8c11091f8a97ae1e4a26302d1dd0598f33c39bfa
Author: PJ Fanning <[email protected]>
AuthorDate: Sun Aug 30 08:37:39 2026 +0100
Percent-decode grpc-message on the pekko-http client (#869)
Motivation:
`grpc-message` travels UTF-8 percent-encoded on the wire. The server side
encodes it, and `Status-Message.parse` exists to decode it, but the
pekko-http
client's `mapToStatusException` read the header value raw. A non-ASCII error
description reached the caller as `%D0%BF...` rather than text. The netty
backend decodes it in grpc-java, so this was pekko-http only.
Fixing that exposed a second bug underneath.
`PercentEncoding.Decoder.decode`
converted its input with `getBytes(US_ASCII)`, which replaces every
non-ASCII
character with '?'. A conforming grpc-message is pure ASCII so this never
bit on
valid input, but the protocol requires that an invalid value is neither
errored
on nor thrown away, and this silently destroyed it. Only values containing a
percent reach the slow path, which is why the existing round-trip tests
missed
it: they always encode first, so the non-ASCII is already escaped by then.
grpc-java does not have this bug. Its `parseAsciiString` takes the wire
bytes
directly and uses US-ASCII only for the two hex digits after a percent,
which
are ASCII by definition. The pekko port takes a String, because
`ModeledCustomHeaderCompanion.parse` hands it one, so it needs a
String-to-bytes
step that grpc-java does not have - and that step picked US-ASCII,
following the
name `parseAsciiString` rather than its data flow.
Modification:
- `mapToStatusException` decodes the header through
`PercentEncoding.Decoder`.
- `Decoder.TransferEncoding` is UTF-8. It is byte-identical to US-ASCII for
conforming input, and preserves the characters of a non-conforming one.
`decodeSlow` already copies payload bytes through one at a time and
decodes
once as UTF-8 at the end, so multi-byte sequences reassemble correctly.
Result:
A percent-encoded `grpc-message` reaches the caller as text on both
backends,
and a non-conforming one is no longer mangled.
Tests:
- 3 cases in `PekkoHttpClientUtilsSpec`: an encoded message, a plain one
that
must pass through untouched, and a broken `%ZZ` escape that must not be
thrown away
- 1 case in `HeadersSpec` for the decoder, covering non-ASCII alongside a
percent - the shape the existing round-trip tests could not reach
- Confirmed both guards bite: reverting the client-side decode fails the
encoded-message case, and restoring US-ASCII fails the decoder case
- sbt "runtime/testOnly ...PekkoHttpClientUtilsSpec ...HeadersSpec" - 14
passed
- sbt "runtime/mimaReportBinaryIssues" - passed
- sbt scalafmtAll scalafmtSbt - applied
- sbt "runtime/test" - not run locally, left to CI
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../pekko/grpc/internal/PekkoHttpClientUtils.scala | 6 +++-
.../grpc/scaladsl/headers/PercentEncoding.scala | 7 +++-
.../grpc/internal/PekkoHttpClientUtilsSpec.scala | 39 ++++++++++++++++++++++
.../pekko/grpc/scaladsl/headers/HeadersSpec.scala | 17 ++++++++++
4 files changed, 67 insertions(+), 2 deletions(-)
diff --git
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtils.scala
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtils.scala
index e864f3ab..91b8ab15 100644
---
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtils.scala
+++
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtils.scala
@@ -23,6 +23,7 @@ import pekko.actor.ClassicActorSystemProvider
import pekko.annotation.InternalApi
import pekko.event.LoggingAdapter
import pekko.grpc.GrpcProtocol.GrpcProtocolReader
+import pekko.grpc.scaladsl.headers.PercentEncoding
import pekko.grpc.{ GrpcClientSettings, GrpcResponseMetadata,
GrpcSingleResponse, ProtobufSerializer }
import pekko.http.scaladsl.model.HttpEntity.{ Chunk, Chunked, LastChunk,
Strict }
import pekko.http.scaladsl.{ ClientTransport, ConnectionContext, Http,
HttpsConnectionContext }
@@ -380,7 +381,10 @@ object PekkoHttpClientUtils {
case None =>
new StatusRuntimeException(mapHttpStatus(response).withDescription("No
grpc-status found"), metadata)
case Some(statusCode) =>
- val description = allHeaders.find(_.name ==
"grpc-message").map(_.value)
+ // grpc-message travels UTF-8 percent-encoded on the wire, so it has
to be decoded
+ // before it reaches the caller. The server side encodes it in
`Status-Message`.
+ val description =
+ allHeaders.find(_.name == "grpc-message").map(h =>
PercentEncoding.Decoder.decode(h.value))
new
StatusRuntimeException(Status.fromCodeValue(statusCode.toInt).withDescription(description.orNull),
metadata)
}
}
diff --git
a/runtime/src/main/scala/org/apache/pekko/grpc/scaladsl/headers/PercentEncoding.scala
b/runtime/src/main/scala/org/apache/pekko/grpc/scaladsl/headers/PercentEncoding.scala
index 5ad342ab..d1f6185a 100644
---
a/runtime/src/main/scala/org/apache/pekko/grpc/scaladsl/headers/PercentEncoding.scala
+++
b/runtime/src/main/scala/org/apache/pekko/grpc/scaladsl/headers/PercentEncoding.scala
@@ -79,7 +79,12 @@ private[grpc] object PercentEncoding {
// Copied with slight adaptations from
https://github.com/grpc/grpc-java/blob/79e75bace40cea7e4be72e7dcd1f41c3ad6ee857/api/src/main/java/io/grpc/Status.java#L626
object Decoder {
- private val TransferEncoding = StandardCharsets.US_ASCII
+ // A conforming value is pure ASCII, for which this is identical to
US-ASCII. It matters for
+ // a non-conforming one: `getBytes(US_ASCII)` turns every non-ASCII
character into '?', and
+ // the protocol requires that an invalid value is neither errored on nor
thrown away.
+ // grpc-java decodes straight off the wire, where the bytes are ASCII by
construction; here
+ // the input is already a String, so it can carry characters that ASCII
cannot represent.
+ private val TransferEncoding = StandardCharsets.UTF_8
def decode(value: String): String =
if (value.indexOf('%') > -1)
diff --git
a/runtime/src/test/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtilsSpec.scala
b/runtime/src/test/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtilsSpec.scala
index 36db8f6a..26c15539 100644
---
a/runtime/src/test/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtilsSpec.scala
+++
b/runtime/src/test/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtilsSpec.scala
@@ -19,6 +19,7 @@ import scala.concurrent.duration._
import org.apache.pekko
import pekko.actor.ActorSystem
import pekko.grpc.GrpcResponseMetadata
+import pekko.grpc.scaladsl.headers.PercentEncoding
import pekko.http.scaladsl.model.HttpEntity.Strict
import pekko.http.scaladsl.model._
import pekko.http.scaladsl.model.StatusCodes._
@@ -64,6 +65,44 @@ class PekkoHttpClientUtilsSpec extends
TestKit(ActorSystem()) with AnyWordSpecLi
failure.asInstanceOf[StatusRuntimeException].getTrailers.get(key) should
be("custom-value-in-header")
}
+ "percent-decode the grpc-message of a failed response" in {
+ // grpc-message travels UTF-8 percent-encoded on the wire; the server
side encodes it with
+ // `PercentEncoding.Encoder`, so an undecoded client hands the caller
the raw escapes
+ val message = "quota exceeded: 100% of 5 µs — café"
+ val encoded = PercentEncoding.Encoder.encode(message)
+ encoded should not be message
+
+ val responseHeaders = RawHeader("grpc-status", "9") ::
RawHeader("grpc-message", encoded) :: Nil
+ val response =
+ Future.successful(HttpResponse(OK, responseHeaders,
Strict(GrpcProtocolNative.contentType, ByteString.empty)))
+
+ val failure = PekkoHttpClientUtils.responseToSource(response,
null).run().failed.futureValue
+
+ failure.asInstanceOf[StatusRuntimeException].getStatus.getDescription
should be(message)
+ }
+
+ "leave an unencoded grpc-message alone" in {
+ val message = "plain ascii failure"
+ val responseHeaders = RawHeader("grpc-status", "9") ::
RawHeader("grpc-message", message) :: Nil
+ val response =
+ Future.successful(HttpResponse(OK, responseHeaders,
Strict(GrpcProtocolNative.contentType, ByteString.empty)))
+
+ val failure = PekkoHttpClientUtils.responseToSource(response,
null).run().failed.futureValue
+
+ failure.asInstanceOf[StatusRuntimeException].getStatus.getDescription
should be(message)
+ }
+
+ "not throw away a grpc-message with a broken escape" in {
+ // the spec requires implementations not to error on invalid values
+ val responseHeaders = RawHeader("grpc-status", "9") ::
RawHeader("grpc-message", "broken %ZZ escape") :: Nil
+ val response =
+ Future.successful(HttpResponse(OK, responseHeaders,
Strict(GrpcProtocolNative.contentType, ByteString.empty)))
+
+ val failure = PekkoHttpClientUtils.responseToSource(response,
null).run().failed.futureValue
+
+ failure.asInstanceOf[StatusRuntimeException].getStatus.getDescription
should be("broken %ZZ escape")
+ }
+
"map a strict 200 response with non-0 gRPC error code with a trailer to a
failed stream with trailer metadata" in {
val responseHeaders = List(RawHeader("grpc-status", "9"))
val responseTrailers = Trailer(
diff --git
a/runtime/src/test/scala/org/apache/pekko/grpc/scaladsl/headers/HeadersSpec.scala
b/runtime/src/test/scala/org/apache/pekko/grpc/scaladsl/headers/HeadersSpec.scala
index 9fdffe71..1bd761d7 100644
---
a/runtime/src/test/scala/org/apache/pekko/grpc/scaladsl/headers/HeadersSpec.scala
+++
b/runtime/src/test/scala/org/apache/pekko/grpc/scaladsl/headers/HeadersSpec.scala
@@ -72,6 +72,23 @@ class HeadersSpec extends AnyWordSpec with Matchers {
actual.get.unencodedValue should equal(expected)
}
}
+
+ "not replace non-ASCII characters when the value also contains a percent"
in {
+ // A conforming grpc-message is pure ASCII, so this only arises for a
non-conforming value.
+ // The protocol requires that such a value is neither errored on nor
thrown away, but
+ // decoding through US-ASCII turned every non-ASCII character into '?'.
Without a percent
+ // the value is returned as is, so a percent is what it takes to reach
the slow path.
+ val inAndExpectedOut = Table(
+ ("raw input", "expected decoded value"),
+ ("100% café", "100% café"),
+ ("50% µs", "50% µs"),
+ ("%41 café", "A café"),
+ ("Καλημέρα 100%", "Καλημέρα 100%"))
+
+ forAll(inAndExpectedOut) { (in, expected) =>
+ `Status-Message`.parse(in).get.unencodedValue should equal(expected)
+ }
+ }
}
"Status-Message.value() and Status-Message.parse()" should {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]