diff --git a/sbt-remote-cache/src/main/scala/sbt/internal/GrpcActionCacheStore.scala b/sbt-remote-cache/src/main/scala/sbt/internal/GrpcActionCacheStore.scala index defd96654..983945313 100644 --- a/sbt-remote-cache/src/main/scala/sbt/internal/GrpcActionCacheStore.scala +++ b/sbt-remote-cache/src/main/scala/sbt/internal/GrpcActionCacheStore.scala @@ -142,9 +142,12 @@ object GrpcActionCacheStore: class AuthCallCredentials(remoteHeaders: List[String]) extends CallCredentials: val pairs = remoteHeaders.map: h => - h.split("=").toList match + // Split on the first '=' only. Splitting on every '=' would drop trailing + // padding from values such as Basic auth credentials ("Basic dXNlcjpwdw==") + // and reject values that legitimately contain '='. + h.split("=", 2).toList match case List(k, v) => Metadata.Key.of(k, Metadata.ASCII_STRING_MARSHALLER) -> v - case _ => sys.error("remote header must contain one '='") + case _ => sys.error("remote header must contain '='") override def applyRequestMetadata( requestInfo: CallCredentials.RequestInfo, executor: java.util.concurrent.Executor, diff --git a/sbt-remote-cache/src/test/scala/sbt/internal/GrpcActionCacheStoreTest.scala b/sbt-remote-cache/src/test/scala/sbt/internal/GrpcActionCacheStoreTest.scala index 14dd56c9b..f7862d970 100644 --- a/sbt-remote-cache/src/test/scala/sbt/internal/GrpcActionCacheStoreTest.scala +++ b/sbt-remote-cache/src/test/scala/sbt/internal/GrpcActionCacheStoreTest.scala @@ -36,6 +36,27 @@ object GrpcActionCacheStoreTest extends verify.BasicTestSuite: // Distinct Deadline instances derived at call time, not a single shared frozen one. assert(!deadline1.eq(deadline2)) + // Regression test: header values may legitimately contain '=' -- Basic auth credentials + // end in base64 padding. Splitting on every '=' silently truncated the value, so the + // server rejected the credential with UNAUTHENTICATED while the build still succeeded, + // leaving the cache permanently empty with no error reported. + test("header values retain '=' such as base64 padding"): + val twoPad = GrpcActionCacheStore.AuthCallCredentials(List("authorization=Basic dXNlcjpwdw==")) + val (key, value) = twoPad.pairs.head + assert(key.name == "authorization") + assert(value == "Basic dXNlcjpwdw==") + + val onePad = GrpcActionCacheStore.AuthCallCredentials(List("authorization=Basic dXNlcjpwdzE=")) + assert(onePad.pairs.head._2 == "Basic dXNlcjpwdzE=") + + // An interior '=' is part of the value, not a second separator. + val interior = GrpcActionCacheStore.AuthCallCredentials(List("x-api-key=ab=cd")) + assert(interior.pairs.head._2 == "ab=cd") + + // No '=' at all remains an error. + intercept[RuntimeException]: + GrpcActionCacheStore.AuthCallCredentials(List("bogus")).pairs + private def newStore(): GrpcActionCacheStore = val base = Files.createTempDirectory("grpc-action-cache-test") val disk = DiskActionCacheStore(base, PlainVirtualFileConverter.converter)