|
@@ -108,6 +108,7 @@ if TYPE_CHECKING:
|
|
|
import dulwich
|
|
import dulwich
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
if TYPE_CHECKING:
|
|
|
|
|
+ from collections.abc import Mapping
|
|
|
from typing import Protocol as TypingProtocol
|
|
from typing import Protocol as TypingProtocol
|
|
|
|
|
|
|
|
from .objects import ObjectID
|
|
from .objects import ObjectID
|
|
@@ -115,14 +116,18 @@ if TYPE_CHECKING:
|
|
|
from .refs import Ref
|
|
from .refs import Ref
|
|
|
|
|
|
|
|
class HTTPResponse(TypingProtocol):
|
|
class HTTPResponse(TypingProtocol):
|
|
|
- """Protocol for HTTP response objects."""
|
|
|
|
|
|
|
+ """Protocol for HTTP response objects (matches urllib3.response.HTTPResponse)."""
|
|
|
|
|
|
|
|
- redirect_location: str | None
|
|
|
|
|
|
|
+ status: int
|
|
|
|
|
+ headers: Mapping[str, str]
|
|
|
content_type: str | None
|
|
content_type: str | None
|
|
|
|
|
+ redirect_location: str
|
|
|
|
|
|
|
|
- def close(self) -> None:
|
|
|
|
|
- """Close the response."""
|
|
|
|
|
- ...
|
|
|
|
|
|
|
+ def close(self) -> None: ...
|
|
|
|
|
+
|
|
|
|
|
+ def read(self, amt: int | None = None) -> bytes: ...
|
|
|
|
|
+
|
|
|
|
|
+ def geturl(self) -> str | None: ...
|
|
|
|
|
|
|
|
class GeneratePackDataFunc(TypingProtocol):
|
|
class GeneratePackDataFunc(TypingProtocol):
|
|
|
"""Protocol for generate_pack_data functions."""
|
|
"""Protocol for generate_pack_data functions."""
|
|
@@ -3466,6 +3471,88 @@ def _urlmatch_http_sections(
|
|
|
yield section
|
|
yield section
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+class AuthCallbackPoolManager:
|
|
|
|
|
+ """Pool manager wrapper that handles authentication callbacks."""
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(
|
|
|
|
|
+ self,
|
|
|
|
|
+ pool_manager: "urllib3.PoolManager | urllib3.ProxyManager",
|
|
|
|
|
+ auth_callback: Callable[[str, str, int], dict[str, str] | None] | None = None,
|
|
|
|
|
+ proxy_auth_callback: Callable[[str, str, int], dict[str, str] | None]
|
|
|
|
|
+ | None = None,
|
|
|
|
|
+ ) -> None:
|
|
|
|
|
+ self._pool_manager = pool_manager
|
|
|
|
|
+ self._auth_callback = auth_callback
|
|
|
|
|
+ self._proxy_auth_callback = proxy_auth_callback
|
|
|
|
|
+ self._auth_attempts: dict[str, int] = {}
|
|
|
|
|
+
|
|
|
|
|
+ def __getattr__(self, name: str): # type: ignore[no-untyped-def]
|
|
|
|
|
+ # Delegate all other attributes to the wrapped pool manager
|
|
|
|
|
+ return getattr(self._pool_manager, name)
|
|
|
|
|
+
|
|
|
|
|
+ def request(self, method: str, url: str, *args, **kwargs): # type: ignore[no-untyped-def]
|
|
|
|
|
+ """Make HTTP request with authentication callback support."""
|
|
|
|
|
+ max_attempts = 3
|
|
|
|
|
+ attempts = self._auth_attempts.get(url, 0)
|
|
|
|
|
+
|
|
|
|
|
+ while attempts < max_attempts:
|
|
|
|
|
+ response = self._pool_manager.request(method, url, *args, **kwargs)
|
|
|
|
|
+
|
|
|
|
|
+ if response.status == 401 and self._auth_callback:
|
|
|
|
|
+ # HTTP authentication required
|
|
|
|
|
+ www_authenticate = response.headers.get("WWW-Authenticate", "")
|
|
|
|
|
+ attempts += 1
|
|
|
|
|
+ self._auth_attempts[url] = attempts
|
|
|
|
|
+
|
|
|
|
|
+ # Call the authentication callback
|
|
|
|
|
+ credentials = self._auth_callback(url, www_authenticate, attempts)
|
|
|
|
|
+ if credentials:
|
|
|
|
|
+ # Update request with new credentials
|
|
|
|
|
+ import urllib3.util
|
|
|
|
|
+
|
|
|
|
|
+ auth_header = urllib3.util.make_headers(
|
|
|
|
|
+ basic_auth=f"{credentials['username']}:{credentials.get('password', '')}"
|
|
|
|
|
+ )
|
|
|
|
|
+ if "headers" in kwargs:
|
|
|
|
|
+ kwargs["headers"].update(auth_header)
|
|
|
|
|
+ else:
|
|
|
|
|
+ kwargs["headers"] = auth_header
|
|
|
|
|
+ # Retry the request
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ elif response.status == 407 and self._proxy_auth_callback:
|
|
|
|
|
+ # Proxy authentication required
|
|
|
|
|
+ proxy_authenticate = response.headers.get("Proxy-Authenticate", "")
|
|
|
|
|
+ attempts += 1
|
|
|
|
|
+ self._auth_attempts[url] = attempts
|
|
|
|
|
+
|
|
|
|
|
+ # Call the proxy authentication callback
|
|
|
|
|
+ credentials = self._proxy_auth_callback(
|
|
|
|
|
+ url, proxy_authenticate, attempts
|
|
|
|
|
+ )
|
|
|
|
|
+ if credentials:
|
|
|
|
|
+ # Update request with new proxy credentials
|
|
|
|
|
+ import urllib3.util
|
|
|
|
|
+
|
|
|
|
|
+ proxy_auth_header = urllib3.util.make_headers(
|
|
|
|
|
+ proxy_basic_auth=f"{credentials['username']}:{credentials.get('password', '')}"
|
|
|
|
|
+ )
|
|
|
|
|
+ if "headers" in kwargs:
|
|
|
|
|
+ kwargs["headers"].update(proxy_auth_header)
|
|
|
|
|
+ else:
|
|
|
|
|
+ kwargs["headers"] = proxy_auth_header
|
|
|
|
|
+ # Retry the request
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # Clear attempts on success or non-auth failure
|
|
|
|
|
+ if url in self._auth_attempts:
|
|
|
|
|
+ del self._auth_attempts[url]
|
|
|
|
|
+ return response
|
|
|
|
|
+
|
|
|
|
|
+ # Max attempts reached
|
|
|
|
|
+ return response
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def default_urllib3_manager(
|
|
def default_urllib3_manager(
|
|
|
config: Config | None,
|
|
config: Config | None,
|
|
|
pool_manager_cls: type | None = None,
|
|
pool_manager_cls: type | None = None,
|
|
@@ -3473,7 +3560,9 @@ def default_urllib3_manager(
|
|
|
base_url: str | None = None,
|
|
base_url: str | None = None,
|
|
|
timeout: float | None = None,
|
|
timeout: float | None = None,
|
|
|
cert_reqs: str | None = None,
|
|
cert_reqs: str | None = None,
|
|
|
-) -> "urllib3.ProxyManager | urllib3.PoolManager":
|
|
|
|
|
|
|
+ auth_callback: Callable[[str, str, int], dict[str, str] | None] | None = None,
|
|
|
|
|
+ proxy_auth_callback: Callable[[str, str, int], dict[str, str] | None] | None = None,
|
|
|
|
|
+) -> "urllib3.ProxyManager | urllib3.PoolManager | AuthCallbackPoolManager":
|
|
|
"""Return urllib3 connection pool manager.
|
|
"""Return urllib3 connection pool manager.
|
|
|
|
|
|
|
|
Honour detected proxy configurations.
|
|
Honour detected proxy configurations.
|
|
@@ -3485,11 +3574,14 @@ def default_urllib3_manager(
|
|
|
base_url: Base URL for proxy bypass checks
|
|
base_url: Base URL for proxy bypass checks
|
|
|
timeout: Timeout for HTTP requests in seconds
|
|
timeout: Timeout for HTTP requests in seconds
|
|
|
cert_reqs: SSL certificate requirements (e.g. "CERT_REQUIRED", "CERT_NONE")
|
|
cert_reqs: SSL certificate requirements (e.g. "CERT_REQUIRED", "CERT_NONE")
|
|
|
|
|
+ auth_callback: Optional callback for HTTP authentication
|
|
|
|
|
+ proxy_auth_callback: Optional callback for proxy authentication
|
|
|
|
|
|
|
|
Returns:
|
|
Returns:
|
|
|
Either pool_manager_cls (defaults to ``urllib3.ProxyManager``) instance for
|
|
Either pool_manager_cls (defaults to ``urllib3.ProxyManager``) instance for
|
|
|
proxy configurations, proxy_manager_cls
|
|
proxy configurations, proxy_manager_cls
|
|
|
- (defaults to ``urllib3.PoolManager``) instance otherwise
|
|
|
|
|
|
|
+ (defaults to ``urllib3.PoolManager``) instance otherwise. If auth callbacks
|
|
|
|
|
+ are provided, returns an AuthCallbackPoolManager wrapper.
|
|
|
|
|
|
|
|
"""
|
|
"""
|
|
|
proxy_server: str | None = None
|
|
proxy_server: str | None = None
|
|
@@ -3611,28 +3703,61 @@ def default_urllib3_manager(
|
|
|
|
|
|
|
|
import urllib3
|
|
import urllib3
|
|
|
|
|
|
|
|
- manager: urllib3.ProxyManager | urllib3.PoolManager
|
|
|
|
|
|
|
+ # Check for proxy authentication method configuration
|
|
|
|
|
+ proxy_auth_method: str | None = None
|
|
|
|
|
+ if config is not None:
|
|
|
|
|
+ try:
|
|
|
|
|
+ proxy_auth_method_bytes = config.get(b"http", b"proxyAuthMethod")
|
|
|
|
|
+ if proxy_auth_method_bytes and isinstance(proxy_auth_method_bytes, bytes):
|
|
|
|
|
+ proxy_auth_method = proxy_auth_method_bytes.decode().lower()
|
|
|
|
|
+ except KeyError:
|
|
|
|
|
+ pass
|
|
|
|
|
+
|
|
|
|
|
+ # Check environment variable override
|
|
|
|
|
+ env_proxy_auth = os.environ.get("GIT_HTTP_PROXY_AUTHMETHOD")
|
|
|
|
|
+ if env_proxy_auth:
|
|
|
|
|
+ proxy_auth_method = env_proxy_auth.lower()
|
|
|
|
|
+
|
|
|
|
|
+ base_manager: urllib3.ProxyManager | urllib3.PoolManager
|
|
|
if proxy_server is not None:
|
|
if proxy_server is not None:
|
|
|
if proxy_manager_cls is None:
|
|
if proxy_manager_cls is None:
|
|
|
proxy_manager_cls = urllib3.ProxyManager
|
|
proxy_manager_cls = urllib3.ProxyManager
|
|
|
if not isinstance(proxy_server, str):
|
|
if not isinstance(proxy_server, str):
|
|
|
proxy_server = proxy_server.decode()
|
|
proxy_server = proxy_server.decode()
|
|
|
proxy_server_url = urlparse(proxy_server)
|
|
proxy_server_url = urlparse(proxy_server)
|
|
|
|
|
+
|
|
|
|
|
+ # Validate proxy auth method if specified
|
|
|
|
|
+ if proxy_auth_method and proxy_auth_method not in ("anyauth", "basic"):
|
|
|
|
|
+ # Only basic and anyauth are currently supported
|
|
|
|
|
+ # Other methods like digest, negotiate, ntlm would require additional libraries
|
|
|
|
|
+ raise NotImplementedError(
|
|
|
|
|
+ f"Proxy authentication method '{proxy_auth_method}' is not supported. "
|
|
|
|
|
+ "Only 'basic' and 'anyauth' are currently supported."
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
if proxy_server_url.username is not None:
|
|
if proxy_server_url.username is not None:
|
|
|
proxy_headers = urllib3.make_headers(
|
|
proxy_headers = urllib3.make_headers(
|
|
|
proxy_basic_auth=f"{proxy_server_url.username}:{proxy_server_url.password or ''}"
|
|
proxy_basic_auth=f"{proxy_server_url.username}:{proxy_server_url.password or ''}"
|
|
|
)
|
|
)
|
|
|
else:
|
|
else:
|
|
|
proxy_headers = {}
|
|
proxy_headers = {}
|
|
|
- manager = proxy_manager_cls(
|
|
|
|
|
|
|
+ base_manager = proxy_manager_cls(
|
|
|
proxy_server, proxy_headers=proxy_headers, headers=headers, **kwargs
|
|
proxy_server, proxy_headers=proxy_headers, headers=headers, **kwargs
|
|
|
)
|
|
)
|
|
|
else:
|
|
else:
|
|
|
if pool_manager_cls is None:
|
|
if pool_manager_cls is None:
|
|
|
pool_manager_cls = urllib3.PoolManager
|
|
pool_manager_cls = urllib3.PoolManager
|
|
|
- manager = pool_manager_cls(headers=headers, **kwargs)
|
|
|
|
|
|
|
+ base_manager = pool_manager_cls(headers=headers, **kwargs)
|
|
|
|
|
+
|
|
|
|
|
+ # Wrap with AuthCallbackPoolManager if callbacks are provided
|
|
|
|
|
+ if auth_callback is not None or proxy_auth_callback is not None:
|
|
|
|
|
+ return AuthCallbackPoolManager(
|
|
|
|
|
+ base_manager,
|
|
|
|
|
+ auth_callback=auth_callback,
|
|
|
|
|
+ proxy_auth_callback=proxy_auth_callback,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- return manager
|
|
|
|
|
|
|
+ return base_manager
|
|
|
|
|
|
|
|
|
|
|
|
|
def check_for_proxy_bypass(base_url: str | None) -> bool:
|
|
def check_for_proxy_bypass(base_url: str | None) -> bool:
|
|
@@ -4345,13 +4470,30 @@ def _wrap_urllib3_exceptions(
|
|
|
|
|
|
|
|
|
|
|
|
|
class Urllib3HttpGitClient(AbstractHttpGitClient):
|
|
class Urllib3HttpGitClient(AbstractHttpGitClient):
|
|
|
- """Git client that uses urllib3 for HTTP(S) connections."""
|
|
|
|
|
|
|
+ """HTTP Git client using urllib3.
|
|
|
|
|
+
|
|
|
|
|
+ Supports callback-based authentication for both HTTP and proxy authentication,
|
|
|
|
|
+ allowing dynamic credential handling without intercepting exceptions.
|
|
|
|
|
+
|
|
|
|
|
+ Example:
|
|
|
|
|
+ >>> def auth_callback(url, www_authenticate, attempt):
|
|
|
|
|
+ ... # Parse www_authenticate header to determine auth scheme
|
|
|
|
|
+ ... # Return credentials or None to cancel
|
|
|
|
|
+ ... return {"username": "user", "password": "pass"}
|
|
|
|
|
+ >>>
|
|
|
|
|
+ >>> client = Urllib3HttpGitClient(
|
|
|
|
|
+ ... "https://github.com/private/repo.git",
|
|
|
|
|
+ ... auth_callback=auth_callback
|
|
|
|
|
+ ... )
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ pool_manager: "urllib3.PoolManager | urllib3.ProxyManager | AuthCallbackPoolManager"
|
|
|
|
|
|
|
|
def __init__(
|
|
def __init__(
|
|
|
self,
|
|
self,
|
|
|
base_url: str,
|
|
base_url: str,
|
|
|
dumb: bool | None = None,
|
|
dumb: bool | None = None,
|
|
|
- pool_manager: "urllib3.PoolManager | None" = None,
|
|
|
|
|
|
|
+ pool_manager: "urllib3.PoolManager | urllib3.ProxyManager | AuthCallbackPoolManager | None" = None,
|
|
|
config: Config | None = None,
|
|
config: Config | None = None,
|
|
|
username: str | None = None,
|
|
username: str | None = None,
|
|
|
password: str | None = None,
|
|
password: str | None = None,
|
|
@@ -4361,16 +4503,27 @@ class Urllib3HttpGitClient(AbstractHttpGitClient):
|
|
|
report_activity: Callable[[int, str], None] | None = None,
|
|
report_activity: Callable[[int, str], None] | None = None,
|
|
|
quiet: bool = False,
|
|
quiet: bool = False,
|
|
|
include_tags: bool = False,
|
|
include_tags: bool = False,
|
|
|
|
|
+ auth_callback: Callable[[str, str, int], dict[str, str] | None] | None = None,
|
|
|
|
|
+ proxy_auth_callback: Callable[[str, str, int], dict[str, str] | None]
|
|
|
|
|
+ | None = None,
|
|
|
) -> None:
|
|
) -> None:
|
|
|
"""Initialize Urllib3HttpGitClient."""
|
|
"""Initialize Urllib3HttpGitClient."""
|
|
|
self._timeout = timeout
|
|
self._timeout = timeout
|
|
|
self._extra_headers = extra_headers or {}
|
|
self._extra_headers = extra_headers or {}
|
|
|
|
|
+ self._auth_callback = auth_callback
|
|
|
|
|
+ self._proxy_auth_callback = proxy_auth_callback
|
|
|
|
|
|
|
|
if pool_manager is None:
|
|
if pool_manager is None:
|
|
|
self.pool_manager = default_urllib3_manager(
|
|
self.pool_manager = default_urllib3_manager(
|
|
|
- config, base_url=base_url, timeout=timeout
|
|
|
|
|
|
|
+ config,
|
|
|
|
|
+ base_url=base_url,
|
|
|
|
|
+ timeout=timeout,
|
|
|
|
|
+ auth_callback=auth_callback,
|
|
|
|
|
+ proxy_auth_callback=proxy_auth_callback,
|
|
|
)
|
|
)
|
|
|
else:
|
|
else:
|
|
|
|
|
+ # Use provided pool manager as-is
|
|
|
|
|
+ # If you want callbacks with a custom pool manager, wrap it yourself
|
|
|
self.pool_manager = pool_manager
|
|
self.pool_manager = pool_manager
|
|
|
|
|
|
|
|
if username is not None:
|
|
if username is not None:
|
|
@@ -4442,15 +4595,10 @@ class Urllib3HttpGitClient(AbstractHttpGitClient):
|
|
|
if resp.status != 200:
|
|
if resp.status != 200:
|
|
|
raise GitProtocolError(f"unexpected http resp {resp.status} for {url}")
|
|
raise GitProtocolError(f"unexpected http resp {resp.status} for {url}")
|
|
|
|
|
|
|
|
- resp.content_type = resp.headers.get("Content-Type") # type: ignore[attr-defined]
|
|
|
|
|
- # Check if geturl() is available (urllib3 version >= 1.23)
|
|
|
|
|
- try:
|
|
|
|
|
- resp_url = resp.geturl()
|
|
|
|
|
- except AttributeError:
|
|
|
|
|
- # get_redirect_location() is available for urllib3 >= 1.1
|
|
|
|
|
- resp.redirect_location = resp.get_redirect_location() # type: ignore[attr-defined]
|
|
|
|
|
- else:
|
|
|
|
|
- resp.redirect_location = resp_url if resp_url != url else "" # type: ignore[attr-defined]
|
|
|
|
|
|
|
+ # With urllib3 >= 2.2, geturl() is always available
|
|
|
|
|
+ resp.content_type = resp.headers.get("Content-Type") # type: ignore[union-attr]
|
|
|
|
|
+ resp_url = resp.geturl()
|
|
|
|
|
+ resp.redirect_location = resp_url if resp_url != url else "" # type: ignore[union-attr]
|
|
|
return resp, _wrap_urllib3_exceptions(resp.read) # type: ignore[return-value]
|
|
return resp, _wrap_urllib3_exceptions(resp.read) # type: ignore[return-value]
|
|
|
|
|
|
|
|
|
|
|