fix(downloader): correct truncation, ranged sizing, and session carry

Check a sequential download against the complete length whenever one is known, and settle the unknown-total case on the 416. An empty body of unknown length, a 206 with no complete length, and a 206 starting past the resume offset all passed the size checks before and were renamed into place. Four restart paths spent no attempt, so a host that always answered the same way cycled forever.
This commit is contained in:
imSp4rky
2026-09-06 20:03:08 -06:00
parent dffb8b27e3
commit 22cc739e83
13 changed files with 1895 additions and 121 deletions
+335
View File
@@ -0,0 +1,335 @@
"""Removing a header or a cookie from an RnetSession must stop it reaching the wire.
``client.update(headers=...)`` merges into the rnet client default headers and has no
removal, and the client keeps its own cookie store. These tests read what a real local
server received, because the session dicts pass even with the leak in place.
"""
from __future__ import annotations
import threading
from http.cookiejar import Cookie, CookieJar
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Iterator
import pytest
import rnet
from unshackle.core.session import RnetCookieAdapter, RnetSession
pytestmark = pytest.mark.unit
class Recorder(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
received: list[dict[str, str]] = []
cookies_sent: list[str] = []
def do_GET(self) -> None:
type(self).received.append({k.lower(): v for k, v in self.headers.items()})
# rnet sends one Cookie header per cookie on HTTP/1.1, so a dict of the headers
# keeps only the last one and hides a cookie that leaked.
type(self).cookies_sent.append("; ".join(self.headers.get_all("Cookie") or []))
body = b"ok"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
if self.path == "/login":
self.send_header("Set-Cookie", "sid=abc123; Path=/")
elif self.path.startswith("/set/"):
self.send_header("Set-Cookie", f"{self.path[len('/set/') :]}; Path=/")
self.end_headers()
self.wfile.write(body)
def log_message(self, *args: object) -> None:
pass
@pytest.fixture
def server() -> Iterator[str]:
Recorder.received = []
Recorder.cookies_sent = []
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Recorder)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
try:
yield f"http://127.0.0.1:{httpd.server_port}"
finally:
httpd.shutdown()
httpd.server_close()
def make_cookie(name: str, value: str, domain: str) -> Cookie:
return Cookie(
version=0,
name=name,
value=value,
port=None,
port_specified=False,
domain=domain,
domain_specified=True,
domain_initial_dot=domain.startswith("."),
path="/",
path_specified=True,
secure=False,
expires=None,
discard=False,
comment=None,
comment_url=None,
rest={},
)
def test_popped_header_stops_being_sent(server: str) -> None:
session = RnetSession()
session.headers.update({"X-Auth": "secret", "X-Keep": "yes"})
session.get(f"{server}/one")
assert Recorder.received[-1]["x-auth"] == "secret"
session.headers.pop("X-Auth", None)
session.get(f"{server}/two")
assert "x-auth" not in Recorder.received[-1]
assert Recorder.received[-1]["x-keep"] == "yes"
def test_deleted_header_stops_being_sent(server: str) -> None:
session = RnetSession()
session.headers["X-Auth"] = "secret"
session.get(f"{server}/one")
assert "x-auth" in Recorder.received[-1]
del session.headers["X-Auth"]
session.get(f"{server}/two")
assert "x-auth" not in Recorder.received[-1]
def test_clear_removes_every_header(server: str) -> None:
session = RnetSession()
session.headers.update({"X-Auth": "secret", "X-Trace": "on"})
session.get(f"{server}/one")
assert "x-auth" in Recorder.received[-1]
session.headers.clear()
session.get(f"{server}/two")
assert "x-auth" not in Recorder.received[-1]
assert "x-trace" not in Recorder.received[-1]
def test_header_removal_keeps_cookies(server: str) -> None:
"""A removed header must not cost the session the cookies it has already earned."""
session = RnetSession()
session.headers["X-Auth"] = "secret"
session.get(f"{server}/login")
session.get(f"{server}/before")
assert Recorder.received[-1].get("cookie") == "sid=abc123"
session.headers.pop("X-Auth", None)
session.get(f"{server}/after")
assert Recorder.received[-1].get("cookie") == "sid=abc123"
def test_surviving_headers_keep_the_impersonate_order(server: str) -> None:
"""A rebuilt client must overlay the preset headers, not replace them."""
session = RnetSession(impersonate=rnet.Impersonate.Chrome131)
session.headers.update({"X-Auth": "secret", "X-Keep": "yes"})
session.get(f"{server}/one")
before = [k for k in Recorder.received[-1] if k != "x-auth"]
session.headers.pop("X-Auth", None)
session.get(f"{server}/two")
assert [k for k in Recorder.received[-1]] == before
def test_header_removal_keeps_cookies_for_a_host_not_yet_visited(server: str) -> None:
"""A cookie unshackle set itself must survive the rebuild.
``rebuild_client`` can only read the rnet cookie jar per origin, and it knows only the
origins the session has requested. Cookies loaded from a cookie file, or set through
``session.cookies``, are for a host the session often reaches later.
"""
session = RnetSession()
session.headers["X-Auth"] = "secret"
session.get(f"{server}/one")
session.cookies.set("sid", "abc123", domain="example.test")
session.headers.pop("X-Auth", None)
raw = session._client.get_cookies("https://example.test")
assert raw is not None and b"sid=abc123" in raw
def test_rebuild_survives_an_origin_arriving_mid_rebuild(server: str, monkeypatch) -> None:
"""``request`` records origins from the download threads while the rebuild reads them.
Iterating the live set raises ``RuntimeError: Set changed size during iteration`` out of the
header removal. Adding an origin from inside the copy stands in for that race.
"""
session = RnetSession()
session.headers["X-Auth"] = "secret"
session.get(f"{server}/one")
original = RnetSession.__dict__["copy_cookies"].__func__
def racing_copy(source, target, origin: str) -> None:
session._origins.add(f"https://late-{len(session._origins)}.test")
original(source, target, origin)
monkeypatch.setattr(RnetSession, "copy_cookies", staticmethod(racing_copy))
session.headers.pop("X-Auth", None)
session.get(f"{server}/two")
assert "x-auth" not in Recorder.received[-1]
def test_clear_drops_a_server_set_cookie(server: str) -> None:
"""``cookies.clear()`` must reach the rnet cookie store, not only the local dicts."""
session = RnetSession()
session.get(f"{server}/set/sid=abc123")
session.get(f"{server}/before")
assert Recorder.cookies_sent[-1] == "sid=abc123"
session.cookies.clear()
session.get(f"{server}/after")
assert Recorder.cookies_sent[-1] == ""
def test_clear_leaves_the_session_able_to_take_new_cookies(server: str) -> None:
"""A cleared session must still hold the next cookie, and only that one."""
session = RnetSession()
session.get(f"{server}/set/sid=abc123")
session.cookies.clear()
session.get(f"{server}/set/fresh=1")
session.get(f"{server}/after")
assert Recorder.cookies_sent[-1] == "fresh=1"
def test_delete_drops_only_the_named_server_set_cookie(server: str) -> None:
"""``del cookies[name]`` must drop that name from the store and leave the rest."""
session = RnetSession()
session.get(f"{server}/set/sid=abc123")
session.get(f"{server}/set/other=keep")
del session.cookies["sid"]
session.get(f"{server}/after")
assert Recorder.cookies_sent[-1] == "other=keep"
def test_clear_by_name_drops_a_server_set_cookie(server: str) -> None:
"""``clear(name=...)`` is the requests spelling a service reaches for."""
session = RnetSession()
session.get(f"{server}/set/sid=abc123")
session.get(f"{server}/before")
assert Recorder.cookies_sent[-1] == "sid=abc123"
session.cookies.clear(name="sid")
session.get(f"{server}/after")
assert Recorder.cookies_sent[-1] == ""
def test_clear_before_the_client_exists_empties_the_jar_and_sends_nothing(server: str) -> None:
"""``clear()`` on a session that has made no request must empty every cookie view.
``dl.save_cookies`` writes ``cookies.jar`` back to the cookie file and only ever merges,
so a cleared cookie left in the jar returns to disk.
"""
session = RnetSession()
jar = CookieJar()
jar.set_cookie(make_cookie("sid", "abc123", "127.0.0.1"))
session.cookies.update(jar)
session.cookies.set("other", "keep", domain="127.0.0.1")
session.cookies.clear()
assert dict(session.cookies) == {}
assert session.cookies.get_dict() == {}
assert list(session.cookies.jar) == []
session.get(f"{server}/after")
assert Recorder.cookies_sent[-1] == ""
def test_a_later_header_rebuild_does_not_resurrect_cleared_cookies(server: str) -> None:
"""``rebuild_client`` replays the buffered cookies and copies the old jar per origin.
A ``clear()`` that left either populated hands the cookie back on the next header removal.
"""
session = RnetSession()
session.headers["X-Auth"] = "secret"
session.get(f"{server}/set/sid=abc123")
session.cookies.clear()
session.headers.pop("X-Auth", None)
session.get(f"{server}/after")
assert Recorder.cookies_sent[-1] == ""
def test_a_request_during_the_rebuild_still_carries_headers_and_cookies(server: str, monkeypatch) -> None:
"""A request from another thread must never see a client with neither headers nor cookies.
``rebuild_client`` publishes the new client before it carries the headers and the cookies,
so a request in that window goes out unauthenticated.
"""
session = RnetSession()
session.headers.update({"X-Auth": "secret", "X-Trace": "on"})
session.get(f"{server}/set/sid=abc123")
session.get(f"{server}/before")
assert Recorder.cookies_sent[-1] == "sid=abc123"
original = RnetCookieAdapter.flush_to_client
rebuilding: list[bool] = []
def flush_with_a_request(self) -> None:
if rebuilding:
session.get(f"{server}/mid")
original(self)
monkeypatch.setattr(RnetCookieAdapter, "flush_to_client", flush_with_a_request)
rebuilding.append(True)
session.headers.pop("X-Trace", None)
assert Recorder.received[-1].get("x-auth") == "secret"
assert Recorder.cookies_sent[-1] == "sid=abc123"
def test_the_rebuilt_client_is_published_only_when_it_is_ready(server: str, monkeypatch) -> None:
"""The new client must go on ``_client`` last, after the headers, the flush and the copy."""
session = RnetSession()
session.headers["X-Auth"] = "secret"
session.get(f"{server}/set/sid=abc123")
old = session._client
seen: list[object] = []
rebuilding: list[bool] = []
original_flush = RnetCookieAdapter.flush_to_client
original_copy = RnetSession.__dict__["copy_cookies"].__func__
def recording_flush(self) -> None:
if rebuilding:
seen.append(session._client)
original_flush(self)
def recording_copy(source, target, origin: str) -> None:
seen.append(session._client)
original_copy(source, target, origin)
monkeypatch.setattr(RnetCookieAdapter, "flush_to_client", recording_flush)
monkeypatch.setattr(RnetSession, "copy_cookies", staticmethod(recording_copy))
rebuilding.append(True)
session.headers.pop("X-Auth", None)
assert seen and all(client is old for client in seen)
assert session._client is not old
+136 -7
View File
@@ -4,11 +4,17 @@ only the adapter mounts."""
from __future__ import annotations
from copy import copy
from typing import Any
from unittest.mock import patch
import pytest
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from unshackle.core.service import TimeoutHTTPAdapter, grow_session_pool
from unshackle.core.service import DEFAULT_TIMEOUT, TimeoutHTTPAdapter, grow_session_pool
from unshackle.core.utils.sslciphers import SSLCiphers
pytestmark = pytest.mark.unit
@@ -20,43 +26,166 @@ class FakeRnetSession:
raise AssertionError("grow_session_pool must not touch a non-requests session")
def test_small_pool_is_remounted_on_both_schemes() -> None:
def sent_timeout(adapter: HTTPAdapter) -> Any:
"""The timeout an adapter hands to HTTPAdapter.send when the caller passes none."""
seen: dict[str, Any] = {}
def record(self: object, request: object, **kwargs: Any) -> str:
seen.update(kwargs)
return "sent"
with patch.object(HTTPAdapter, "send", record):
assert adapter.send(object(), timeout=None) == "sent"
return seen["timeout"]
def test_subclass_adapter_keeps_its_class_and_tls_context() -> None:
"""A service-mounted SSLCiphers must survive the growth with its TLS context.
CBS, friDay, iP, MY5 and PMTP all mount SSLCiphers on the service session and none
override get_session(). Replacing it with a plain adapter drops DEFAULT:@SECLEVEL=2 and
check_hostname=False, so metadata succeeds and every segment then fails at TLS or 403.
"""
session = requests.Session()
session.mount("https://", SSLCiphers(security_level=2))
old = session.get_adapter("https://x")
context = old._ssl_context
assert old._pool_maxsize < 64
grow_session_pool(session, 64)
grown = session.get_adapter("https://x")
assert grown is old
assert isinstance(grown, SSLCiphers)
assert grown._ssl_context is context
assert grown.poolmanager.connection_pool_kw["ssl_context"] is context
assert grown._pool_maxsize == 64
assert grown._pool_block is True
def test_a_distinct_http_adapter_is_grown_not_overwritten() -> None:
"""Every mounted adapter grows, and no mount overwrites another.
The https:// adapter must not land on the http:// prefix, and a service that mounts on a
narrow prefix must be grown as well.
"""
session = requests.Session()
secure = SSLCiphers(security_level=2)
narrow = SSLCiphers(security_level=2)
plain = TimeoutHTTPAdapter(pool_connections=10, pool_maxsize=10)
session.mount("https://", secure)
session.mount("https://cbs.example/", narrow)
session.mount("http://", plain)
grow_session_pool(session, 64)
assert session.get_adapter("https://x") is secure
assert session.get_adapter("https://cbs.example/a") is narrow
assert session.get_adapter("http://x") is plain
assert session.get_adapter("http://x") is not session.get_adapter("https://x")
for adapter in (secure, narrow, plain):
assert adapter._pool_maxsize == 64
assert adapter._pool_connections == 64
def test_shared_adapter_is_grown_in_place_on_both_schemes() -> None:
session = requests.Session()
adapter = TimeoutHTTPAdapter(pool_connections=10, pool_maxsize=10, max_retries=Retry(total=5))
session.mount("https://", adapter)
session.mount("http://", adapter)
retries = adapter.max_retries
timeout = adapter.default_timeout
grow_session_pool(session, 64)
https = session.get_adapter("https://x")
http = session.get_adapter("http://x")
assert isinstance(https, TimeoutHTTPAdapter)
assert https is http
assert https is not old
assert https is adapter
assert session.get_adapter("http://x") is adapter
assert type(https) is TimeoutHTTPAdapter
assert https.max_retries is retries
assert https.default_timeout == timeout
assert https._pool_maxsize == 64
assert https._pool_connections == 64
assert https._pool_block is True
assert https.max_retries is old.max_retries
assert https.poolmanager.connection_pool_kw["maxsize"] == 64
def test_cached_proxy_manager_is_rebuilt_at_the_new_size() -> None:
"""proxy_manager_for caches one manager per proxy URL, sized when it was first built.
authenticate() and get_tracks() run before the growth, so a service behind --proxy has
already filled that cache. Leaving it there keeps every proxied segment on the small pool.
"""
session = requests.Session()
adapter = TimeoutHTTPAdapter(pool_connections=4, pool_maxsize=4)
session.mount("https://", adapter)
proxy = "http://proxy.example:8080"
stale = adapter.proxy_manager_for(proxy)
assert stale.connection_pool_kw["maxsize"] == 4
grow_session_pool(session, 64)
fresh = adapter.proxy_manager_for(proxy)
assert fresh.connection_pool_kw["maxsize"] == 64
assert fresh is not stale
def test_large_enough_pool_is_left_alone() -> None:
session = requests.Session()
session.mount("https://", HTTPAdapter(pool_connections=64, pool_maxsize=64))
old = session.get_adapter("https://x")
pool = old.poolmanager
grow_session_pool(session, 64)
assert session.get_adapter("https://x") is old
assert old.poolmanager is pool, "an adapter that needs no growth must keep its warm connections"
def test_equal_size_pool_is_left_alone() -> None:
session = requests.Session()
session.mount("https://", HTTPAdapter(pool_maxsize=32))
old = session.get_adapter("https://x")
pool = old.poolmanager
grow_session_pool(session, 32)
assert session.get_adapter("https://x") is old
assert old.poolmanager is pool
@pytest.mark.parametrize("session", [object(), FakeRnetSession()])
def test_non_requests_session_is_ignored(session: object) -> None:
grow_session_pool(session, 64)
def test_copied_adapter_keeps_the_default_timeout() -> None:
"""default_timeout must survive copy(), which no_retry_session runs per segment.
copy() runs HTTPAdapter.__getstate__/__setstate__, which carry only the names in
__attrs__. Without default_timeout there, the copy quietly falls back to DEFAULT_TIMEOUT
on the segment path and drops the service timeout.
The last two lines pin the getattr fallback in send(). A subclass that redefines
__attrs__ from HTTPAdapter.__attrs__, as SSLCiphers does, drops default_timeout again.
"""
twin = copy(TimeoutHTTPAdapter(timeout=(3, 300), pool_connections=4, pool_maxsize=4))
assert twin.default_timeout == (3, 300)
assert sent_timeout(twin) == (3, 300)
del twin.default_timeout
assert sent_timeout(twin) == DEFAULT_TIMEOUT
def test_growing_does_not_revert_a_custom_default_timeout() -> None:
session = requests.Session()
session.mount("https://", TimeoutHTTPAdapter(timeout=(1, 5), pool_connections=4, pool_maxsize=4))
old = session.get_adapter("https://x")
grow_session_pool(session, 64)
grown = session.get_adapter("https://x")
assert grown.default_timeout == (1, 5)
assert grown is old
assert copy(grown).default_timeout == (1, 5), "the per-segment copy must keep the service timeout"