You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by xu...@apache.org on 2026/10/21 10:00:17 UTC

(opendal) branch main updated: fix(services/http): read a response that carries no content length (#8385)

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

Xuanwo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opendal.git


The following commit(s) were added to refs/heads/main by this push:
     new 8984cfadc fix(services/http): read a response that carries no content length (#8385)
8984cfadc is described below

commit 8984cfadc09bf1961cc0240e7401dc7b9c035d87
Author: Ayush Baluni <73...@users.noreply.github.com>
AuthorDate: Wed Oct 21 15:30:11 2026 +0530

    fix(services/http): read a response that carries no content length (#8385)
    
    Content-Length is optional in HTTP. An HTTP/1.1 response can be framed
    with Transfer-Encoding: chunked (RFC 9112 6.1) and an HTTP/2 response is
    framed by the end of the stream (RFC 9113 8.1.1), so neither carries one.
    Since 0.59 every read of such a response failed with "HTTP response does
    not contain file content length".
    
    The requirement comes from the metadata invariant in #8196 -- a file's
    Metadata always carries its complete content length -- and that invariant
    is left intact here. parse_into_metadata still refuses to invent a length.
    
    What changes is that services-http was asking for metadata it does not
    need in order to read. RpRead holds an Option<Metadata> precisely so a
    service can decline to report metadata the read did not observe
    natively, and services-ipmfs, services-ftp and services-gcs-grpc already
    return RpRead::default() for the same reason. So when the response states
    no length, report no metadata and stream the body.
    
    The length is derived exactly as parse_into_metadata derives it, so the
    two cannot disagree about whether one is present, and a malformed
    Content-Length still errors rather than being read as absent.
    
    A caller that asks for Reader::metadata() on such a response now gets
    ErrorKind::Unsupported, which core/tests/behavior/async_read.rs already
    accepts as the answer from a service that does not report read metadata.
    
    stat is deliberately unchanged: RpStat::new takes a Metadata rather than
    an Option, so on a HEAD with no length there is nothing correct for it to
    return, and answering with EntryMode::Unknown would change what stat
    promises about a file that does exist.
    
    Signed-off-by: aayushbaluni <73...@users.noreply.github.com>
    Co-authored-by: dentiny <de...@gmail.com>
---
 core/services/http/src/reader.rs | 109 +++++++++++++++++++++++++++++++++++++--
 1 file changed, 105 insertions(+), 4 deletions(-)

diff --git a/core/services/http/src/reader.rs b/core/services/http/src/reader.rs
index 52ef4ae59..11ba17ea0 100644
--- a/core/services/http/src/reader.rs
+++ b/core/services/http/src/reader.rs
@@ -56,10 +56,9 @@ impl oio::StreamRead for HttpReader {
         let status = resp.status();
 
         let (rp, stream) = match status {
-            StatusCode::OK | StatusCode::PARTIAL_CONTENT => (
-                RpRead::new(parse_into_metadata(path, resp.headers())?),
-                resp.into_body(),
-            ),
+            StatusCode::OK | StatusCode::PARTIAL_CONTENT => {
+                (read_metadata(path, resp.headers())?, resp.into_body())
+            }
             _ => {
                 let (part, mut body) = resp.into_parts();
                 let buf = body.to_buffer().await?;
@@ -73,3 +72,105 @@ impl oio::StreamRead for HttpReader {
         Ok((rp, Box::new(stream) as Box<dyn oio::ReadStreamDyn>))
     }
 }
+
+/// Build the read reply from a successful response's headers.
+///
+/// `Content-Length` is optional in HTTP: an HTTP/1.1 response can be framed
+/// with `Transfer-Encoding: chunked` (RFC 9112 section 6.1) and an HTTP/2
+/// response is framed by the end of the stream (RFC 9113 section 8.1.1), so
+/// neither carries one. RFC 9110 section 8.6 only says a server *should* send
+/// it when the length is known in advance.
+///
+/// `parse_into_metadata` requires a length for a file, because `Metadata` for a
+/// file always carries its complete content length. That requirement is correct
+/// for `stat`, but a read does not need it: `RpRead` holds an
+/// `Option<Metadata>` precisely so a service can decline to report metadata
+/// that the read did not observe natively. So when the response states no
+/// length, report no metadata and let the body stream.
+///
+/// A caller that explicitly asks for `Reader::metadata()` then gets
+/// `ErrorKind::Unsupported`, which is the documented answer for a service that
+/// does not return metadata on read. Before this, the read itself failed.
+fn read_metadata(path: &str, headers: &http::HeaderMap) -> Result<RpRead> {
+    // Mirrors how parse_into_metadata derives the length, so the two cannot
+    // disagree about whether one is present. A malformed header still errors
+    // here rather than being silently treated as absent.
+    let content_length = parse_content_range(headers)?
+        .and_then(|value| value.size())
+        .or(parse_content_length(headers)?);
+
+    match content_length {
+        Some(_) => Ok(RpRead::new(parse_into_metadata(path, headers)?)),
+        None => Ok(RpRead::default()),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use http::HeaderMap;
+    use http::HeaderValue;
+
+    use super::*;
+
+    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
+        let mut headers = HeaderMap::new();
+        for (name, value) in pairs {
+            headers.insert(
+                http::header::HeaderName::from_bytes(name.as_bytes()).expect("name must be valid"),
+                HeaderValue::from_str(value).expect("value must be valid"),
+            );
+        }
+        headers
+    }
+
+    #[test]
+    fn test_read_metadata_with_content_length() {
+        let rp = read_metadata("file.txt", &headers(&[("content-length", "12")]))
+            .expect("read must be accepted");
+
+        let meta = rp.metadata().expect("metadata must be reported");
+        assert_eq!(meta.content_length(), 12);
+    }
+
+    #[test]
+    fn test_read_metadata_from_content_range() {
+        // A ranged read states the full size in Content-Range, not
+        // Content-Length, and that is still a length.
+        let rp = read_metadata(
+            "file.txt",
+            &headers(&[("content-length", "4"), ("content-range", "bytes 0-3/12")]),
+        )
+        .expect("read must be accepted");
+
+        let meta = rp.metadata().expect("metadata must be reported");
+        assert_eq!(meta.content_length(), 12);
+    }
+
+    #[test]
+    fn test_read_metadata_without_content_length_reports_none() {
+        // A chunked HTTP/1.1 response, and the shape an HTTP/2 response takes:
+        // no length anywhere. The read must still be accepted.
+        let rp = read_metadata("file.txt", &headers(&[("transfer-encoding", "chunked")]))
+            .expect("a response with no length must still be read");
+
+        assert!(rp.metadata().is_none());
+    }
+
+    #[test]
+    fn test_read_metadata_without_any_headers_reports_none() {
+        let rp = read_metadata("file.txt", &HeaderMap::new())
+            .expect("a response with no headers must still be read");
+
+        assert!(rp.metadata().is_none());
+    }
+
+    #[test]
+    fn test_read_metadata_rejects_a_malformed_content_length() {
+        // Absent is not the same as unparsable: a server that sends garbage
+        // should still be reported rather than treated as length-free.
+        let err = read_metadata("file.txt", &headers(&[("content-length", "not-a-number")]))
+            .expect_err("a malformed length must be an error");
+
+        assert_eq!(err.kind(), ErrorKind::Unexpected);
+    }
+}