Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/2296.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Briefcase will now default to using the system certificate store when performing HTTPS downloads.
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ dependencies = [
"setuptools >= 60",
"wheel >= 0.37",
"build >= 0.10",
# truststore is only available on 3.10+
"truststore >= 0.10.1; python_version >= '3.10'",
#
# For the remaining packages: We set the lower version limit to the lowest possible
# version that satisfies our API needs. If the package uses semver, we set a limit
Expand Down
30 changes: 29 additions & 1 deletion src/briefcase/integrations/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import itertools
import os
import shutil
import ssl
import sys
import tempfile
from collections.abc import Iterable, Sequence
Expand All @@ -12,6 +13,12 @@

import httpx

if sys.version_info >= (3, 10): # pragma: no-cover-if-lt-py310
import truststore
else: # pragma: no-cover-if-gte-py310
# truststore is only available for python 3.10+
truststore = None

from briefcase.exceptions import (
BadNetworkResourceError,
MissingNetworkResourceError,
Expand Down Expand Up @@ -93,6 +100,22 @@ def sorted_depth_first_groups(
)
)

@property
def ssl_context(self):
"""The SSL context to use for downloads."""
Comment on lines +103 to +105
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a test that this function returns the correct value or type?

try:
return self._ssl_context
except AttributeError:
if sys.version_info >= (3, 10): # pragma: no-cover-if-lt-py310
# Set up a TLS trust store based on the system root certificates
self._ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
else: # pragma: no-cover-if-gte-py310
# Truststore requires Python 3.10; on older versions, fall back to the
# certifi-based store.
self._ssl_context = True

return self._ssl_context

def is_archive(self, filename: str | os.PathLike) -> bool:
"""Can a file be unpacked via `shutil.unpack_archive()`?

Expand Down Expand Up @@ -174,7 +197,12 @@ def download(self, url: str, download_path: Path, role: str | None = None) -> Pa
download_path.mkdir(parents=True, exist_ok=True)
filename: Path = None
try:
with self.tools.httpx.stream("GET", url, follow_redirects=True) as response:
with self.tools.httpx.stream(
"GET",
url,
follow_redirects=True,
verify=self.ssl_context,
) as response:
if response.status_code == 404:
raise MissingNetworkResourceError(url=url)
elif response.status_code != 200:
Expand Down
8 changes: 8 additions & 0 deletions tests/integrations/file/test_File__download.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ def test_new_download_oneshot(mock_tools, file_perms, url, content_disposition):
"GET",
"https://example.com/support?useful=Yes",
follow_redirects=True,
verify=mock_tools.file.ssl_context,
)
response.headers.get.assert_called_with("content-length")
response.read.assert_called_once()
Expand Down Expand Up @@ -213,6 +214,7 @@ def test_new_download_chunked(mock_tools, file_perms):
"GET",
"https://example.com/support?useful=Yes",
follow_redirects=True,
verify=mock_tools.file.ssl_context,
)
response.headers.get.assert_called_with("content-length")
response.iter_bytes.assert_called_once_with(chunk_size=1048576)
Expand Down Expand Up @@ -276,6 +278,7 @@ def test_already_downloaded(mock_tools):
"GET",
url,
follow_redirects=True,
verify=mock_tools.file.ssl_context,
)

# The request's Content-Disposition header is consumed to
Expand Down Expand Up @@ -316,6 +319,7 @@ def test_missing_resource(mock_tools):
"GET",
url,
follow_redirects=True,
verify=mock_tools.file.ssl_context,
)
response.headers.get.assert_not_called()

Expand Down Expand Up @@ -351,6 +355,7 @@ def test_bad_resource(mock_tools):
"GET",
url,
follow_redirects=True,
verify=mock_tools.file.ssl_context,
)
response.headers.get.assert_not_called()

Expand Down Expand Up @@ -389,6 +394,7 @@ def test_iter_bytes_connection_error(mock_tools):
"GET",
"https://example.com/something.zip?useful=Yes",
follow_redirects=True,
verify=mock_tools.file.ssl_context,
)
response.headers.get.assert_called_with("content-length")

Expand Down Expand Up @@ -430,6 +436,7 @@ def test_connection_error(mock_tools):
"GET",
url,
follow_redirects=True,
verify=mock_tools.file.ssl_context,
)

# The file doesn't exist as a result of the download failure
Expand Down Expand Up @@ -466,6 +473,7 @@ def test_redirect_connection_error(mock_tools):
"GET",
"https://example.com/something.zip?useful=Yes",
follow_redirects=True,
verify=mock_tools.file.ssl_context,
)

# The file doesn't exist as a result of the download failure
Expand Down
17 changes: 17 additions & 0 deletions tests/integrations/file/test_File__ssl_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import sys

if sys.version_info >= (3, 10): # pragma: no-cover-if-lt-py310
import truststore
else: # pragma: no-cover-if-gte-py310
# truststore is only available for python 3.10+
truststore = None


def test_ssl_context(mock_tools):
"""The SSL context is of the expected type."""
if sys.version_info >= (3, 10):
expected_type = truststore.SSLContext
else:
expected_type = bool

assert isinstance(mock_tools.file.ssl_context, expected_type)