mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 21:15:06 +00:00
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.
This commit is contained in:
@@ -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"))
|
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):
|
def test_range_item_retry_rewrites_slice(server, tmp_path, monkeypatch):
|
||||||
server.truncate_first = True
|
server.truncate_first = True
|
||||||
monkeypatch.setattr(dl, "RETRY_WAIT", 0.01)
|
monkeypatch.setattr(dl, "RETRY_WAIT", 0.01)
|
||||||
|
|||||||
@@ -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 {}))
|
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:
|
def _force_unblock_stream(stream: Any) -> None:
|
||||||
"""Best-effort: shut the stream's socket so a thread parked in a blocking read returns now.
|
"""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
|
resume_offset = 0
|
||||||
if part_mode and stream.status_code != 206:
|
if part_mode and stream.status_code != 206:
|
||||||
raise IOError(f"expected 206 for ranged part, got {stream.status_code}")
|
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:
|
if use_rnet:
|
||||||
content_encoded = _is_content_encoded(
|
content_encoded = _is_content_encoded(
|
||||||
stream.headers.get("Content-Encoding") or stream.headers.get("content-encoding")
|
stream.headers.get("Content-Encoding") or stream.headers.get("content-encoding")
|
||||||
@@ -728,6 +740,14 @@ def download(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
content_length = 0
|
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:
|
if resumed and content_encoded:
|
||||||
try:
|
try:
|
||||||
stream.close()
|
stream.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user