fix: render Content-Length for HEAD responses with a declared length (#1237)

Motivation:
PR #962 corrected Content-Length rendering for 205 and CONNECT, but it also
made HEAD responses drop the header unconditionally. RFC 9110 section 8.6
allows a server to send Content-Length in a HEAD response, and RFC 9112
section 6.3 rule 1 exempts HEAD responses from body framing, so the header is
metadata there and cannot desync a connection. Suppressing it breaks the
common case of using HEAD to learn the size of a resource without fetching it,
and it is asymmetric: our own client parser honours a HEAD Content-Length, and
the HTTP/2 renderer never consulted contentLengthAllowed at all.

Modification:
Restore the pre-1.4 predicate for HEAD (contentLengthAllowedForHead now
defers to StatusCode.allowsEntity) and add the length-based policy at render
time: a HEAD response renders Content-Length only when the entity declares a
length greater than zero. A zero length nearly always means the application
had no body to hand over rather than that the resource is empty, and the
client parser already only honours a HEAD Content-Length when it is greater
than zero. Chunked and CloseDelimited entities have no known length and are
unchanged. 205, 204, 304 and CONNECT behaviour from #962 is untouched.

Result:
A HEAD response completed with HttpEntity.Strict or
HttpEntity.Default(contentType, length, Source.empty) renders the declared
Content-Length again, while an empty entity renders none.

Tests:
- sbt "http-core / Test / testOnly org.apache.pekko.http.impl.engine.rendering.ResponseRendererSpec org.apache.pekko.http.impl.engine.server.HttpServerSpec org.apache.pekko.http.scaladsl.model.HttpMethodsSpec" - 110 passed
- sbt "http-core / Test / testOnly org.apache.pekko.http.impl.engine.client.HostConnectionPoolSpec" - 66 passed
- sbt +mimaReportBinaryIssues - success
- scalafmt --mode diff-ref=upstream/main - clean
- git diff --check - clean
- sbt "http-core / test" and sbt "docs / paradox" - not run to completion locally, left to CI

References:
Fixes #1236, Refs #962
diff --git a/docs/src/main/paradox/routing-dsl/directives/method-directives/head.md b/docs/src/main/paradox/routing-dsl/directives/method-directives/head.md
index b0ef2b5..b900a55 100644
--- a/docs/src/main/paradox/routing-dsl/directives/method-directives/head.md
+++ b/docs/src/main/paradox/routing-dsl/directives/method-directives/head.md
@@ -23,6 +23,14 @@
 this behavior.
 @@@
 
+@@@ note
+The response body is stripped off, but the `Content-Length` header is still rendered when the entity declares a
+non-zero length, so that clients can learn the size of the resource without fetching it. Entities without a known
+length (`Chunked`, `CloseDelimited`) and empty entities render no `Content-Length`; if you want to answer a HEAD
+request with the size of the hypothetical GET response without producing the bytes, complete with
+`HttpEntity.Default(contentType, length, Source.empty)`.
+@@@
+
 ## Example
 
 Scala
diff --git a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/rendering/HttpResponseRendererFactory.scala b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/rendering/HttpResponseRendererFactory.scala
index d869da4..bf9a8dd 100644
--- a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/rendering/HttpResponseRendererFactory.scala
+++ b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/rendering/HttpResponseRendererFactory.scala
@@ -252,8 +252,16 @@
               r ~~ `Transfer-Encoding` ~~ ChunkedBytes ~~ CrLf
           }
 
+          // RFC 9112 section 6.3 rule 1 exempts responses to HEAD requests from body framing, so a Content-Length
+          // is pure metadata there and cannot desync the connection. Only render a length the application actually
+          // declared: a zero length nearly always means that there was no body to hand over rather than that the
+          // resource is empty, and our own client only honours a HEAD Content-Length when it is greater than zero
+          // (see HttpResponseParser).
           def renderContentLengthHeader(contentLength: Long) =
-            if (ctx.requestMethod.contentLengthAllowed(status)) r ~~ ContentLengthBytes ~~ contentLength ~~ CrLf else r
+            if (ctx.requestMethod.contentLengthAllowed(status) &&
+              (contentLength > 0 || ctx.requestMethod != HttpMethods.HEAD))
+              r ~~ ContentLengthBytes ~~ contentLength ~~ CrLf
+            else r
 
           def headersAndEntity(entityBytes: => Source[ByteString, Any]): StrictOrStreamed =
             if (noEntity) {
diff --git a/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/HttpMethod.scala b/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/HttpMethod.scala
index d407986..128fbdd 100644
--- a/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/HttpMethod.scala
+++ b/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/HttpMethod.scala
@@ -95,8 +95,10 @@
   // for CONNECT it is explicitly not allowed in the 2xx (Successful) range
   private def contentLengthAllowedForConnect(forStatus: StatusCode): Boolean = forStatus.intValue < 200 ||
     forStatus.intValue >= 300
-  // for HEAD it is technically allowed, but must match the content-length of hypothetical GET request, so can not be anticipated
-  private def contentLengthAllowedForHead(forStatus: StatusCode): Boolean = false
+  // for HEAD it is allowed (RFC 9110 section 8.6) and should match the content-length of the hypothetical GET
+  // request; the renderer additionally suppresses a zero length, which usually means that the application had no
+  // body to hand over rather than that the resource is empty
+  private def contentLengthAllowedForHead(forStatus: StatusCode): Boolean = forStatus.allowsEntity
   // for other methods there are common rules:
   // - for 1xx (Informational) or 204 (No Content) it is explicitly not allowed
   // - for 304 (Not Modified) it must match the content-length of hypothetical 200-accepted request, so can not be anticipated
diff --git a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/client/HostConnectionPoolSpec.scala b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/client/HostConnectionPoolSpec.scala
index d39a108..9b71936 100644
--- a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/client/HostConnectionPoolSpec.scala
+++ b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/client/HostConnectionPoolSpec.scala
@@ -223,7 +223,7 @@
             conn1.pushResponse(HttpResponse(entity = HttpEntity.Default(ContentTypes.`application/octet-stream`, 100,
               Source.empty)))
             val res = expectResponse()
-            res.entity.contentLengthOption.get shouldEqual 0
+            res.entity.contentLengthOption.get shouldEqual 100
 
             // HEAD requests do not require to consume entity
 
@@ -242,7 +242,7 @@
             conn1.pushResponse(HttpResponse(entity = HttpEntity.Default(ContentTypes.`application/octet-stream`, 100,
               Source.empty)))
             val res = expectResponse()
-            res.entity.contentLengthOption.get shouldEqual 0
+            res.entity.contentLengthOption.get shouldEqual 100
 
             // HEAD requests do not require consumption of entity but users might do anyway
             res.entity.discardBytes()
diff --git a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/rendering/ResponseRendererSpec.scala b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/rendering/ResponseRendererSpec.scala
index d2fa564..7009473 100644
--- a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/rendering/ResponseRendererSpec.scala
+++ b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/rendering/ResponseRendererSpec.scala
@@ -155,6 +155,21 @@
               |Server: pekko-http/1.0.0
               |Date: Thu, 25 Aug 2011 09:10:29 GMT
               |Content-Type: text/plain; charset=UTF-8
+              |Content-Length: 23
+              |
+              |""", close = false)
+      }
+
+      "to a transparent HEAD request (empty Strict response entity)" in new TestSetup() {
+        ResponseRenderingContext(
+          requestMethod = HttpMethods.HEAD,
+          response = HttpResponse(
+            headers = List(Age(30), Connection("Keep-Alive")),
+            entity = HttpEntity.Empty)) should renderTo(
+          """HTTP/1.1 200 OK
+              |Age: 30
+              |Server: pekko-http/1.0.0
+              |Date: Thu, 25 Aug 2011 09:10:29 GMT
               |
               |""", close = false)
       }
@@ -206,6 +221,7 @@
               |Server: pekko-http/1.0.0
               |Date: Thu, 25 Aug 2011 09:10:29 GMT
               |Content-Type: text/plain; charset=UTF-8
+              |Content-Length: 100
               |
               |""", close = false)
       }
@@ -714,7 +730,7 @@
                  |Server: pekko-http/1.0.0
                  |Date: Thu, 25 Aug 2011 09:10:29 GMT
                  |${renCH.fold("")(_.toString + "\n")}Content-Type: text/plain; charset=UTF-8
-                 |${if (headReq || resCD) "" else "Content-Length: 6\n"}
+                 |${if (resCD) "" else "Content-Length: 6\n"}
                  |${if (headReq) "" else "ENTITY"}""", close))
     }
   }
diff --git a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/server/HttpServerSpec.scala b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/server/HttpServerSpec.scala
index 85e5e14..6d73f29 100644
--- a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/server/HttpServerSpec.scala
+++ b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/server/HttpServerSpec.scala
@@ -523,6 +523,7 @@
                |Server: pekko-http/test
                |Date: XXXX
                |Content-Type: text/plain; charset=UTF-8
+               |Content-Length: 4
                |
                |""")
         }
@@ -551,6 +552,7 @@
                |Server: pekko-http/test
                |Date: XXXX
                |Content-Type: text/plain; charset=UTF-8
+               |Content-Length: 4
                |
                |""")
         }