From 397cbae221a84e42e1680d718ea1574a7dad41f5 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Fri, 7 Aug 2026 11:38:52 -0600 Subject: [PATCH] fix(downloader): accept a 200 whose body is exactly the requested byte-range slice RFC 9110 permits a server to ignore Range and answer 200 with the whole resource. When the item's slice starts at byte 0 and spans exactly the response body, that 200 is byte-identical to the 206, so keep it instead of burning all five attempts and failing the track. Every other 200 (nonzero start, span mismatch, open-ended, encoded) still fails the attempt to protect the merge. --- tests/downloaders/test_download_hardening.py | 11 ++++++++ unshackle/core/downloaders/requests.py | 28 +++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/tests/downloaders/test_download_hardening.py b/tests/downloaders/test_download_hardening.py index e5947c2b..b48a6824 100644 --- a/tests/downloaders/test_download_hardening.py +++ b/tests/downloaders/test_download_hardening.py @@ -115,6 +115,17 @@ def test_range_item_ignored_by_server_fails_not_corrupts(server, tmp_path, monke assert not list(tmp_path.glob("seg_*.bin")) +def test_range_item_spanning_whole_resource_accepts_200(server, tmp_path): + # RFC 9110 lets a server ignore Range and answer 200 with the whole resource; when + # the slice spans that whole resource the body is byte-identical to the 206, so it + # must be kept instead of burning five attempts and failing the track + server.honor_range = False + urls = [{"url": _url(server), "headers": {"Range": f"bytes=0-{len(PARENT) - 1}"}}] + files = _run(server, tmp_path, urls, max_workers=1) + assert files[0].read_bytes() == PARENT + assert len(server.requests) == 1 + + def test_range_item_retry_rewrites_slice(server, tmp_path, monkeypatch): server.truncate_first = True monkeypatch.setattr(dl, "RETRY_WAIT", 0.01) diff --git a/unshackle/core/downloaders/requests.py b/unshackle/core/downloaders/requests.py index b7a6f40c..d424cba1 100644 --- a/unshackle/core/downloaders/requests.py +++ b/unshackle/core/downloaders/requests.py @@ -406,6 +406,22 @@ def _has_range_header(item: dict[str, Any]) -> bool: return any(k.lower() == "range" for k in (item.get("headers") or {})) +def _range_covers_full_body(headers: MutableMapping[str, Any], content_length: int) -> bool: + """True when a 200 body is exactly the bytes the item's Range slice asked for. + + A server may ignore Range and answer 200 with the whole resource (RFC 9110 + permits this). That body is only the right bytes when the slice starts at byte 0 + and spans exactly the full body; anything else (open-ended, multi-range, nonzero + start, span mismatch) stays fail-closed. + """ + value = str(next((v for k, v in headers.items() if k.lower() == "range"), "")) + match = re.fullmatch(r"bytes=(\d+)-(\d+)", value.strip()) + if not match or content_length <= 0: + return False + start, end = int(match.group(1)), int(match.group(2)) + return start == 0 and content_length == end + 1 + + def _force_unblock_stream(stream: Any) -> None: """Best-effort: shut the stream's socket so a thread parked in a blocking read returns now. @@ -710,10 +726,6 @@ def download( resume_offset = 0 if part_mode and stream.status_code != 206: raise IOError(f"expected 206 for ranged part, got {stream.status_code}") - if item_range and not part_mode and stream.status_code != 206: - # server ignored the slice's Range and sent the whole parent; writing that - # as the segment would silently corrupt the merge, so fail the attempt - raise IOError(f"expected 206 for byte-range segment, got {stream.status_code}") if use_rnet: content_encoded = _is_content_encoded( stream.headers.get("Content-Encoding") or stream.headers.get("content-encoding") @@ -728,6 +740,14 @@ def download( except ValueError: content_length = 0 + if item_range and not part_mode and stream.status_code != 206: + # server ignored the slice's Range and sent the whole parent (RFC 9110 allows + # a 200 here); writing that as the segment would silently corrupt the merge, + # so fail the attempt — unless the slice spans the whole resource, in which + # case the 200 body is byte-identical to the 206 and safe to keep + if not (stream.status_code == 200 and _range_covers_full_body(req_headers, content_length)): + raise IOError(f"expected 206 for byte-range segment, got {stream.status_code}") + if resumed and content_encoded: try: stream.close()