From e4c517d902f94d6dceff8a2bc29865f1a6d0a6d8 Mon Sep 17 00:00:00 2001 From: Punisheroot <44579963+Punisheroot@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:21:35 +0200 Subject: [PATCH 1/2] feat(download): support proxy environment variables --- src/_native/bits.cpp | 44 +++++++--- src/_native/proxy.cpp | 56 ++++++++++++ src/_native/proxy.h | 19 ++++ src/_native/winhttp.cpp | 70 +++++++++++++-- src/manage/urlutils.py | 125 +++++++++++++++++++++++++- tests/localserver.py | 25 ++++++ tests/test_urlutils.py | 188 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 505 insertions(+), 22 deletions(-) create mode 100644 src/_native/proxy.cpp create mode 100644 src/_native/proxy.h diff --git a/src/_native/bits.cpp b/src/_native/bits.cpp index b3dd011..80636ab 100644 --- a/src/_native/bits.cpp +++ b/src/_native/bits.cpp @@ -5,6 +5,7 @@ #include #include "helpers.h" +#include "proxy.h" #ifdef BITS_INJECT_ERROR static HRESULT _inject_hr[] @@ -205,7 +206,18 @@ PyObject *bits_serialize_job(PyObject *, PyObject *args, PyObject *kwargs) { } -static HRESULT _job_setproxy(IBackgroundCopyJob *job) { +static HRESULT _job_setproxy(IBackgroundCopyJob *job, const ProxySettings *settings) { + if (settings->mode == PROXY_MODE_DIRECT) { + return job->SetProxySettings(BG_JOB_PROXY_USAGE_NO_PROXY, NULL, NULL); + } + if (settings->mode == PROXY_MODE_OVERRIDE) { + return job->SetProxySettings( + BG_JOB_PROXY_USAGE_OVERRIDE, + settings->proxy_list, + NULL + ); + } + WINHTTP_CURRENT_USER_IE_PROXY_CONFIG proxy_config = { 0 }; if (WinHttpGetIEProxyConfigForCurrentUser(&proxy_config)) { if (proxy_config.lpszProxy || proxy_config.lpszAutoConfigUrl || proxy_config.fAutoDetect) { @@ -257,18 +269,21 @@ static HRESULT _job_setcredentials( return hr; } -// (conn, name, url, path, [username], [password]) -> job +// (conn, name, url, path, [username], [password], [proxy_settings]) -> job PyObject *bits_begin(PyObject *, PyObject *args, PyObject *kwargs) { - static const char * keywords[] = {"conn", "name", "url", "path", "username", "password", NULL}; + static const char * keywords[] = { + "conn", "name", "url", "path", "username", "password", "proxy_settings", NULL + }; IBackgroundCopyManager *bcm = NULL; wchar_t *name = NULL; wchar_t *url = NULL; wchar_t *path = NULL; wchar_t *username = NULL; wchar_t *password = NULL; - if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O&O&O&O&|O&O&:bits_begin", keywords, + PyObject *proxy_settings_obj = Py_None; + if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O&O&O&O&|O&O&O:bits_begin", keywords, from_capsule, &bcm, as_utf16, &name, as_utf16, &url, as_utf16, &path, - as_utf16, &username, as_utf16, &password + as_utf16, &username, as_utf16, &password, &proxy_settings_obj )) { return NULL; } @@ -276,20 +291,28 @@ PyObject *bits_begin(PyObject *, PyObject *args, PyObject *kwargs) { PyObject *r = NULL; GUID jobId; IBackgroundCopyJob* job = NULL; + ProxySettings proxy_settings = {}; HRESULT hr; + if (!proxy_settings_parse(proxy_settings_obj, &proxy_settings)) { + goto done; + } if (FAILED(hr = _inject_hr[0]) || FAILED(hr = bcm->CreateJob(name, BG_JOB_TYPE_DOWNLOAD, &jobId, &job))) { error_from_bits_hr(bcm, hr, "Creating download job"); goto done; } - if (FAILED(hr = _job_setproxy(job))) { + if (FAILED(hr = _job_setproxy(job, &proxy_settings))) { error_from_bits_hr(bcm, hr, "Setting proxy"); goto done; } - // Setting proxy credentials to NULL will automatically infer credentials - // if needed. It's a good default (provided users have not configured a - // malicious proxy server, which we can't do anything about here anyway). - if (FAILED(hr = _job_setcredentials(job, BG_AUTH_TARGET_PROXY, NULL, NULL))) { + // Explicit proxy credentials come from the configured proxy URL. NULL + // automatically infers credentials if needed, which remains the default. + if (FAILED(hr = _job_setcredentials( + job, + BG_AUTH_TARGET_PROXY, + proxy_settings.username, + proxy_settings.password + ))) { error_from_bits_hr(bcm, hr, "Setting proxy credentials"); goto done; } @@ -317,6 +340,7 @@ PyObject *bits_begin(PyObject *, PyObject *args, PyObject *kwargs) { if (job) { job->Release(); } + proxy_settings_clear(&proxy_settings); PyMem_Free(path); PyMem_Free(url); PyMem_Free(name); diff --git a/src/_native/proxy.cpp b/src/_native/proxy.cpp new file mode 100644 index 0000000..1a07cdc --- /dev/null +++ b/src/_native/proxy.cpp @@ -0,0 +1,56 @@ +#include + +#include "helpers.h" +#include "proxy.h" + +int proxy_settings_parse(PyObject *obj, ProxySettings *settings) { + settings->mode = PROXY_MODE_AUTO; + settings->proxy_list = NULL; + settings->username = NULL; + settings->password = NULL; + + if (!obj || Py_Is(obj, Py_GetConstantBorrowed(Py_CONSTANT_NONE))) { + return 1; + } + + int mode; + if (!PyArg_ParseTuple( + obj, + "iO&O&O&:proxy_settings", + &mode, + as_utf16, &settings->proxy_list, + as_utf16, &settings->username, + as_utf16, &settings->password + )) { + // PyArg_ParseTuple calls cleanup for successful O& converters when a + // later conversion fails, so these pointers no longer own memory. + settings->proxy_list = NULL; + settings->username = NULL; + settings->password = NULL; + return 0; + } + + if (mode < PROXY_MODE_AUTO || mode > PROXY_MODE_OVERRIDE) { + PyErr_SetString(PyExc_ValueError, "invalid proxy mode"); + proxy_settings_clear(settings); + return 0; + } + settings->mode = (ProxyMode)mode; + + if (settings->mode == PROXY_MODE_OVERRIDE && !settings->proxy_list) { + PyErr_SetString(PyExc_ValueError, "proxy override requires a proxy list"); + proxy_settings_clear(settings); + return 0; + } + return 1; +} + +void proxy_settings_clear(ProxySettings *settings) { + PyMem_Free(settings->proxy_list); + PyMem_Free(settings->username); + PyMem_Free(settings->password); + settings->mode = PROXY_MODE_AUTO; + settings->proxy_list = NULL; + settings->username = NULL; + settings->password = NULL; +} diff --git a/src/_native/proxy.h b/src/_native/proxy.h new file mode 100644 index 0000000..f2db20a --- /dev/null +++ b/src/_native/proxy.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +enum ProxyMode { + PROXY_MODE_AUTO = 0, + PROXY_MODE_DIRECT = 1, + PROXY_MODE_OVERRIDE = 2, +}; + +struct ProxySettings { + ProxyMode mode; + wchar_t *proxy_list; + wchar_t *username; + wchar_t *password; +}; + +int proxy_settings_parse(PyObject *obj, ProxySettings *settings); +void proxy_settings_clear(ProxySettings *settings); diff --git a/src/_native/winhttp.cpp b/src/_native/winhttp.cpp index 8d7d580..2d77e6b 100644 --- a/src/_native/winhttp.cpp +++ b/src/_native/winhttp.cpp @@ -5,6 +5,7 @@ #include #include "helpers.h" +#include "proxy.h" #pragma comment(lib, "winhttp.lib") @@ -191,7 +192,12 @@ extern "C" { #define CHECK_WINHTTP(x) if (!x) { winhttp_error(); goto exit; } -static bool winhttp_apply_proxy(HINTERNET hSession, HINTERNET hRequest, const wchar_t *url) { +static bool winhttp_apply_proxy( + HINTERNET hSession, + HINTERNET hRequest, + const wchar_t *url, + const ProxySettings *settings +) { bool result = false; WINHTTP_CURRENT_USER_IE_PROXY_CONFIG proxy_config = { 0 }; WINHTTP_AUTOPROXY_OPTIONS proxy_opt = { @@ -200,6 +206,42 @@ static bool winhttp_apply_proxy(HINTERNET hSession, HINTERNET hRequest, const wc }; WINHTTP_PROXY_INFO proxy_info = { 0 }; + if (settings->mode == PROXY_MODE_DIRECT) { + // The session may have inherited a static WinHTTP proxy, so explicitly + // override it rather than relying on the absence of request settings. + proxy_info.dwAccessType = WINHTTP_ACCESS_TYPE_NO_PROXY; + CHECK_WINHTTP(WinHttpSetOption( + hRequest, + WINHTTP_OPTION_PROXY, + &proxy_info, + sizeof(proxy_info) + )); + result = true; + goto exit; + } + + if (settings->mode == PROXY_MODE_OVERRIDE) { + proxy_info.dwAccessType = WINHTTP_ACCESS_TYPE_NAMED_PROXY; + proxy_info.lpszProxy = settings->proxy_list; + + CHECK_WINHTTP(WinHttpSetCredentials( + hRequest, + WINHTTP_AUTH_TARGET_PROXY, + settings->username ? WINHTTP_AUTH_SCHEME_BASIC : WINHTTP_AUTH_SCHEME_NEGOTIATE, + settings->username, + settings->username ? settings->password : NULL, + NULL + )); + CHECK_WINHTTP(WinHttpSetOption( + hRequest, + WINHTTP_OPTION_PROXY, + &proxy_info, + sizeof(proxy_info) + )); + result = true; + goto exit; + } + // First load the global-ish config settings if (!WinHttpGetIEProxyConfigForCurrentUser(&proxy_config)) { if (GetLastError() != ERROR_FILE_NOT_FOUND) { @@ -248,10 +290,10 @@ static bool winhttp_apply_proxy(HINTERNET hSession, HINTERNET hRequest, const wc result = true; exit: - if (proxy_info.lpszProxy) { + if (settings->mode == PROXY_MODE_AUTO && proxy_info.lpszProxy) { GlobalFree((HGLOBAL)proxy_info.lpszProxy); } - if (proxy_info.lpszProxyBypass) { + if (settings->mode == PROXY_MODE_AUTO && proxy_info.lpszProxyBypass) { GlobalFree((HGLOBAL)proxy_info.lpszProxyBypass); } if (proxy_opt.lpszAutoConfigUrl) { @@ -262,13 +304,17 @@ static bool winhttp_apply_proxy(HINTERNET hSession, HINTERNET hRequest, const wc PyObject *winhttp_urlopen(PyObject *, PyObject *args, PyObject *kwargs) { - static const char * keywords[] = {"url", "method", "headers", "accepts", "chunksize", "on_progress", "on_cred_request", NULL}; + static const char * keywords[] = { + "url", "method", "headers", "accepts", "chunksize", + "on_progress", "on_cred_request", "proxy_settings", NULL + }; wchar_t *url = NULL; wchar_t *method = NULL; wchar_t *headers = NULL; wchar_t *accepts = NULL; PyObject *on_progress = NULL; PyObject *on_cred_request = NULL; + PyObject *proxy_settings_obj = Py_None; PyObject *result = NULL; URL_COMPONENTS url_parts = { sizeof(URL_COMPONENTS) }; @@ -276,7 +322,8 @@ PyObject *winhttp_urlopen(PyObject *, PyObject *args, PyObject *kwargs) { HINTERNET hConnection = NULL; HINTERNET hRequest = NULL; DWORD opt = 0; - LPCWSTR *accepts_array; + LPCWSTR *accepts_array = NULL; + ProxySettings proxy_settings = {}; Py_ssize_t chunksize = 65536; DWORD status_code = 0; @@ -289,10 +336,14 @@ PyObject *winhttp_urlopen(PyObject *, PyObject *args, PyObject *kwargs) { wchar_t *user = NULL; wchar_t *pass = NULL; - if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O&O&O&O&|nOO:winhttp_urlopen", keywords, - as_utf16, &url, as_utf16, &method, as_utf16, &headers, as_utf16, &accepts, &chunksize, &on_progress, &on_cred_request)) { + if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O&O&O&O&|nOOO:winhttp_urlopen", keywords, + as_utf16, &url, as_utf16, &method, as_utf16, &headers, as_utf16, &accepts, + &chunksize, &on_progress, &on_cred_request, &proxy_settings_obj)) { return NULL; } + if (!proxy_settings_parse(proxy_settings_obj, &proxy_settings)) { + goto exit; + } if (on_progress && !PyObject_IsTrue(on_progress)) { on_progress = NULL; @@ -370,7 +421,7 @@ PyObject *winhttp_urlopen(PyObject *, PyObject *args, PyObject *kwargs) { ); CHECK_WINHTTP(hRequest); - CHECK_WINHTTP(winhttp_apply_proxy(hSession, hRequest, url)); + CHECK_WINHTTP(winhttp_apply_proxy(hSession, hRequest, url, &proxy_settings)); opt = WINHTTP_DECOMPRESSION_FLAG_ALL; CHECK_WINHTTP(WinHttpSetOption( @@ -514,6 +565,7 @@ PyObject *winhttp_urlopen(PyObject *, PyObject *args, PyObject *kwargs) { } PyMem_Free(user); PyMem_Free(pass); + proxy_settings_clear(&proxy_settings); PyMem_Free(hostname); PyMem_Free(urlpath); PyMem_Free(accepts_array); @@ -645,4 +697,4 @@ PyObject *winhttp_urlunsplit(PyObject *, PyObject *args, PyObject *kwargs) { } -} \ No newline at end of file +} diff --git a/src/manage/urlutils.py b/src/manage/urlutils.py index f88f0a1..aa63664 100644 --- a/src/manage/urlutils.py +++ b/src/manage/urlutils.py @@ -60,6 +60,82 @@ def winhttp_urlunsplit(*a): SUPPORTED_SCHEMES = "http".casefold(), "https".casefold(), "file".casefold() +PROXY_MODE_AUTO = 0 +PROXY_MODE_DIRECT = 1 +PROXY_MODE_OVERRIDE = 2 + + +class _ProxySettings: + __slots__ = ("mode", "proxy_list", "powershell_proxy", "username", "password") + + def __init__( + self, + mode=PROXY_MODE_AUTO, + proxy_list=None, + powershell_proxy=None, + username=None, + password=None, + ): + self.mode = mode + self.proxy_list = proxy_list + self.powershell_proxy = powershell_proxy + self.username = username + self.password = password + + def as_native(self): + return self.mode, self.proxy_list, self.username, self.password + + +def _parse_proxy(value): + parts = list(winhttp_urlsplit(value if "://" in value else f"http://{value}")) + if not parts[U_NETLOC]: + raise ValueError("Proxy URL does not contain a host") + + has_credentials = parts[U_USERNAME] is not None or parts[U_PASSWORD] is not None + credentials = None + if has_credentials: + credentials = parts[U_USERNAME] or "", parts[U_PASSWORD] or "" + + parts[U_USERNAME] = None + parts[U_PASSWORD] = None + parts[U_PATH] = "" + parts[U_EXTRA] = "" + proxy = winhttp_urlunsplit(*parts).removesuffix("/") + return proxy, credentials + + +def _proxy_settings_from_env(): + if os.getenv("NO_PROXY"): + return _ProxySettings(mode=PROXY_MODE_DIRECT) + + http_value = os.getenv("HTTP_PROXY") + https_value = os.getenv("HTTPS_PROXY") + if not http_value and not https_value: + return _ProxySettings() + + http_proxy = http_credentials = None + https_proxy = https_credentials = None + if http_value: + http_proxy, http_credentials = _parse_proxy(http_value) + if https_value: + https_proxy, https_credentials = _parse_proxy(https_value) + + proxy_list = " ".join( + value for value in ( + f"http={http_proxy}" if http_proxy else None, + f"https={https_proxy}" if https_proxy else None, + ) if value + ) + credentials = https_credentials or http_credentials or (None, None) + return _ProxySettings( + mode=PROXY_MODE_OVERRIDE, + proxy_list=proxy_list, + powershell_proxy=https_proxy or http_proxy, + username=credentials[0], + password=credentials[1], + ) + + class NoInternetError(Exception): pass @@ -73,6 +149,7 @@ def __init__(self, url, method="GET", headers={}, outfile=None): self.username = None self.password = None self.outfile = Path(outfile) if outfile else None + self.proxy_settings = _proxy_settings_from_env() self._on_progress = None self._on_auth_request = None @@ -127,7 +204,13 @@ def _bits_urlretrieve(request): if not job: LOGGER.debug("Starting new BITS job: %s -> %s", request, outfile) ensure_tree(outfile) - job = bits_begin(bits, PurePath(outfile).name, request.url, outfile) + job = bits_begin( + bits, + PurePath(outfile).name, + request.url, + outfile, + proxy_settings=request.proxy_settings.as_native(), + ) LOGGER.debug("Writing %s", jobfile) jobfile.write_bytes(bits_serialize_job(bits, job)) @@ -173,7 +256,8 @@ def _winhttp_urlopen(request): LOGGER.debug("winhttp_urlopen: %s", request) try: data = winhttp_urlopen(request.url, method, header_str, accept, - request.chunksize, request.on_progress, request.on_auth_request) + request.chunksize, request.on_progress, request.on_auth_request, + request.proxy_settings.as_native()) except OSError as ex: if ex.winerror == 0x00002EE7: LOGGER.debug("winhttp_isconnected: %s", winhttp_isconnected()) @@ -295,6 +379,23 @@ def _powershell_urlretrieve(request): $url = $env:PYMANAGER_URL $outfile = $env:PYMANAGER_OUTFILE $method = $env:PYMANAGER_METHOD +$proxyMode = $env:PYMANAGER_PROXY_MODE +$proxy = $env:PYMANAGER_PROXY +$proxyUser = $env:PYMANAGER_PROXY_USERNAME +$proxyPassword = $env:PYMANAGER_PROXY_PASSWORD +$proxyHasCredentials = $env:PYMANAGER_PROXY_HAS_CREDENTIALS -eq "1" +$proxyArgs = @{} +if ($proxyMode -eq "direct") { + [System.Net.WebRequest]::DefaultWebProxy = [System.Net.WebProxy]::new() +} elseif ($proxyMode -eq "override") { + $proxyArgs["Proxy"] = [Uri]$proxy + if ($proxyHasCredentials) { + $securePassword = ConvertTo-SecureString $proxyPassword -AsPlainText -Force + $proxyArgs["ProxyCredential"] = [pscredential]::new($proxyUser, $securePassword) + } else { + $proxyArgs["ProxyUseDefaultCredentials"] = $true + } +} $headersObj = ConvertFrom-Json $env:PYMANAGER_HEADERS $headers = @{} if ($headersObj -ne $null) { @@ -308,7 +409,8 @@ def _powershell_urlretrieve(request): -Headers $headers ` -UseDefaultCredentials ` -Method $method ` - -OutFile $outfile + -OutFile $outfile ` + @proxyArgs """ LOGGER.debug("PowerShell download invoked (env-based)") env = os.environ.copy() @@ -317,6 +419,23 @@ def _powershell_urlretrieve(request): "PYMANAGER_OUTFILE": str(request.outfile), "PYMANAGER_METHOD": request.method, "PYMANAGER_HEADERS": json.dumps(headers), + "PYMANAGER_PROXY_MODE": { + PROXY_MODE_AUTO: "auto", + PROXY_MODE_DIRECT: "direct", + PROXY_MODE_OVERRIDE: "override", + }[request.proxy_settings.mode], + "PYMANAGER_PROXY": request.proxy_settings.powershell_proxy or "", + "PYMANAGER_PROXY_USERNAME": request.proxy_settings.username or "", + "PYMANAGER_PROXY_PASSWORD": request.proxy_settings.password or "", + "PYMANAGER_PROXY_HAS_CREDENTIALS": ( + "1" if any( + value is not None + for value in ( + request.proxy_settings.username, + request.proxy_settings.password, + ) + ) else "0" + ), }) with subprocess.Popen( [powershell, diff --git a/tests/localserver.py b/tests/localserver.py index ebec8a8..af281b2 100644 --- a/tests/localserver.py +++ b/tests/localserver.py @@ -6,6 +6,31 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self, header_only=False): + if self.path.startswith("http://"): + from base64 import b64decode + from urllib.parse import urlsplit + + target = urlsplit(self.path).path + if target == "/through-proxy": + body = b"Proxy OK" + elif target == "/through-auth-proxy": + auth = self.headers.get("Proxy-Authorization") + if not auth: + self.send_response(407) + self.send_header("Proxy-Authenticate", 'Basic realm="test"') + self.end_headers() + return + kind, _, value = auth.partition(" ") + body = b"Proxy " + kind.encode() + b" " + b64decode(value) + else: + self.send_error(502) + return + self.send_response(200) + self.send_header("Content-Length", len(body)) + self.end_headers() + if not header_only: + self.wfile.write(body) + return if self.path == "/stop": self.send_response(200) self.end_headers() diff --git a/tests/test_urlutils.py b/tests/test_urlutils.py index 289856a..7d77e78 100644 --- a/tests/test_urlutils.py +++ b/tests/test_urlutils.py @@ -7,6 +7,194 @@ import manage.urlutils as UU +@pytest.fixture +def clean_proxy_env(monkeypatch): + for name in ("NO_PROXY", "HTTP_PROXY", "HTTPS_PROXY"): + monkeypatch.delenv(name, raising=False) + + +def test_proxy_settings_auto(clean_proxy_env): + settings = UU._proxy_settings_from_env() + assert settings.mode == UU.PROXY_MODE_AUTO + assert settings.proxy_list is None + assert settings.powershell_proxy is None + assert settings.username is None + assert settings.password is None + + +def test_proxy_settings_no_proxy(clean_proxy_env, monkeypatch): + monkeypatch.setenv("NO_PROXY", "example.com") + monkeypatch.setenv("HTTP_PROXY", "http://proxy.example:8080") + + settings = UU._proxy_settings_from_env() + assert settings.mode == UU.PROXY_MODE_DIRECT + assert settings.proxy_list is None + assert settings.powershell_proxy is None + + +def test_proxy_settings_override(clean_proxy_env, monkeypatch): + monkeypatch.setenv( + "HTTP_PROXY", + "http://http%40user:http%40password@http-proxy.example:8080/path", + ) + monkeypatch.setenv( + "HTTPS_PROXY", + "https://https%40user:https%40password@https-proxy.example:8443/path", + ) + + settings = UU._proxy_settings_from_env() + assert settings.mode == UU.PROXY_MODE_OVERRIDE + assert settings.proxy_list == ( + "http=http://http-proxy.example:8080 " + "https=https://https-proxy.example:8443" + ) + assert settings.powershell_proxy == "https://https-proxy.example:8443" + assert settings.username == "https@user" + assert settings.password == "https@password" + assert "http@password" not in settings.proxy_list + assert "https@password" not in settings.proxy_list + + +def test_proxy_settings_http_credentials_fallback(clean_proxy_env, monkeypatch): + monkeypatch.setenv( + "HTTP_PROXY", + "proxy%40user:proxy%40password@proxy.example:8080", + ) + monkeypatch.setenv("HTTPS_PROXY", "https://secure-proxy.example:8443") + + settings = UU._proxy_settings_from_env() + assert settings.proxy_list == ( + "http=http://proxy.example:8080 " + "https=https://secure-proxy.example:8443" + ) + assert settings.powershell_proxy == "https://secure-proxy.example:8443" + assert settings.username == "proxy@user" + assert settings.password == "proxy@password" + + +def test_winhttp_proxy_env(clean_proxy_env, monkeypatch, localserver): + monkeypatch.setenv("HTTP_PROXY", localserver) + request = UU._Request("http://proxy-target.invalid/through-proxy") + + assert UU._winhttp_urlopen(request) == b"Proxy OK" + + +def test_winhttp_proxy_env_auth(clean_proxy_env, monkeypatch, localserver): + proxy = localserver.replace( + "http://", + "http://proxy-user:proxy-password@", + ) + monkeypatch.setenv("HTTP_PROXY", proxy) + request = UU._Request("http://proxy-target.invalid/through-auth-proxy") + + assert UU._winhttp_urlopen(request) == b"Proxy Basic proxy-user:proxy-password" + + +def test_powershell_proxy_env(clean_proxy_env, monkeypatch, localserver, tmp_path): + monkeypatch.setenv("HTTPS_PROXY", localserver) + request = UU._Request( + "http://proxy-target.invalid/through-proxy", + outfile=tmp_path / "proxy.txt", + ) + + UU._powershell_urlretrieve(request) + + assert request.outfile.read_bytes() == b"Proxy OK" + + +def test_powershell_proxy_env_auth( + clean_proxy_env, + monkeypatch, + localserver, + tmp_path, +): + proxy = localserver.replace( + "http://", + "http://proxy-user:proxy-password@", + ) + monkeypatch.setenv("HTTPS_PROXY", proxy) + request = UU._Request( + "http://proxy-target.invalid/through-auth-proxy", + outfile=tmp_path / "proxy-auth.txt", + ) + + UU._powershell_urlretrieve(request) + + assert request.outfile.read_bytes() == b"Proxy Basic proxy-user:proxy-password" + + +def test_powershell_no_proxy_env( + clean_proxy_env, + monkeypatch, + localserver, + tmp_path, +): + monkeypatch.setenv("HTTP_PROXY", "http://127.0.0.1:1") + monkeypatch.setenv("NO_PROXY", "anything") + request = UU._Request( + localserver + "/1kb", + outfile=tmp_path / "direct.txt", + ) + + UU._powershell_urlretrieve(request) + + assert len(request.outfile.read_bytes()) == 1024 + + +def test_bits_proxy_env(clean_proxy_env, monkeypatch, localserver, tmp_path): + monkeypatch.setenv("HTTP_PROXY", localserver) + request = UU._Request( + "http://proxy-target.invalid/through-proxy", + outfile=tmp_path / "proxy.txt", + ) + + UU._bits_urlretrieve(request) + + assert request.outfile.read_bytes() == b"Proxy OK" + + +def test_bits_proxy_env_auth(clean_proxy_env, monkeypatch, localserver, tmp_path): + proxy = localserver.replace( + "http://", + "http://proxy-user:proxy-password@", + ) + monkeypatch.setenv("HTTP_PROXY", proxy) + request = UU._Request( + "http://proxy-target.invalid/through-auth-proxy", + outfile=tmp_path / "proxy-auth.txt", + ) + + UU._bits_urlretrieve(request) + + assert request.outfile.read_bytes() == b"Proxy Basic proxy-user:proxy-password" + + +def test_winhttp_no_proxy_env(clean_proxy_env, monkeypatch, localserver): + monkeypatch.setenv("HTTP_PROXY", "http://127.0.0.1:1") + monkeypatch.setenv("NO_PROXY", "anything") + request = UU._Request(localserver + "/1kb") + + assert UU._winhttp_urlopen(request) + + +def test_bits_no_proxy_env( + clean_proxy_env, + monkeypatch, + localserver, + tmp_path, +): + monkeypatch.setenv("HTTP_PROXY", "http://127.0.0.1:1") + monkeypatch.setenv("NO_PROXY", "anything") + request = UU._Request( + localserver + "/1kb", + outfile=tmp_path / "direct.txt", + ) + + UU._bits_urlretrieve(request) + + assert request.outfile.is_file() + + @pytest.mark.parametrize("url, expect", [pytest.param(*i, id=i[0]) for i in [ ("https://example.com/", "https://example.com/"), ("https://user@example.com/", "https://example.com/"), From 383c3018a8d0cf9bece73353c64ecca0db665130 Mon Sep 17 00:00:00 2001 From: Punisheroot <44579963+Punisheroot@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:02:45 +0200 Subject: [PATCH 2/2] refactor(download): drop unnecessary proxy settings slots --- src/manage/urlutils.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/manage/urlutils.py b/src/manage/urlutils.py index aa63664..ef079bc 100644 --- a/src/manage/urlutils.py +++ b/src/manage/urlutils.py @@ -66,8 +66,6 @@ def winhttp_urlunsplit(*a): class _ProxySettings: - __slots__ = ("mode", "proxy_list", "powershell_proxy", "username", "password") - def __init__( self, mode=PROXY_MODE_AUTO,