22
33Archives are a third VCS type alongside ``git`` and ``svn``. They represent
44versioned dependencies that are distributed as ``.tar.gz``, ``.tgz``,
5- ``.tar.bz2``, ``.tar.xz`` or ``.zip`` files reachable via any URL that Python's
6- :mod:`urllib.request` understands (``http://``, `` https://``, ``file://``, …) .
5+ ``.tar.bz2``, ``.tar.xz`` or ``.zip`` files reachable via ``http://``,
6+ `` https://``, or ``file://`` URLs .
77
88Unlike git and SVN, archives have no inherent "branching" or "tagging"
99concept. Version identity is expressed through:
1010
11- * **No hash** – the URL itself acts as the identity. The archive is
11+ * **No hash** - the URL itself acts as the identity. The archive is
1212 considered up-to-date as long as the same URL is still reachable.
13- * **``integrity.hash: <algorithm>:<hex>``** – the cryptographic hash of the
13+ * **``integrity.hash: <algorithm>:<hex>``** - the cryptographic hash of the
1414 archive file acts as the version identifier. The fetch step verifies the
1515 downloaded archive against this hash and raises an error on mismatch.
1616
4141
4242from __future__ import annotations
4343
44+ import hmac
45+ import http .client
4446import os
4547import pathlib
4648import tempfile
47- import urllib .request as _ur
4849
4950from dfetch .log import get_logger
5051from dfetch .manifest .project import ProjectEntry
5152from dfetch .manifest .version import Version
5253from dfetch .project .subproject import SubProject
5354from dfetch .vcs .archive import (
54- _safe_compare_hex , # private helper, intentionally imported for internal use
55- )
56- from dfetch .vcs .archive import (
57- _suffix_for_url , # private helper, intentionally imported for internal use
58- )
59- from dfetch .vcs .archive import (
55+ ARCHIVE_EXTENSIONS ,
6056 SUPPORTED_HASH_ALGORITHMS ,
6157 ArchiveLocalRepo ,
6258 ArchiveRemote ,
6763logger = get_logger (__name__ )
6864
6965
66+ def _safe_compare_hex (actual : str , expected : str ) -> bool :
67+ """Constant-time comparison of two hex digest strings.
68+
69+ Uses :func:`hmac.compare_digest` to avoid leaking timing information about
70+ the expected hash value.
71+ """
72+ return hmac .compare_digest (actual .lower (), expected .lower ())
73+
74+
75+ def _suffix_for_url (url : str ) -> str :
76+ """Return the archive file suffix for *url* (e.g. ``'.tar.gz'``, ``'.zip'``)."""
77+ lower = url .lower ()
78+ for ext in sorted (ARCHIVE_EXTENSIONS , key = len , reverse = True ):
79+ if lower .endswith (ext ):
80+ return ext
81+ return ".archive"
82+
83+
7084class ArchiveSubProject (SubProject ):
7185 """A project fetched from a tar/zip archive URL.
7286
@@ -83,10 +97,6 @@ def __init__(self, project: ProjectEntry) -> None:
8397 self ._project_entry = project
8498 self ._remote_repo = ArchiveRemote (project .remote_url )
8599
86- # ------------------------------------------------------------------
87- # SubProject abstract interface
88- # ------------------------------------------------------------------
89-
90100 def check (self ) -> bool :
91101 """Return *True* when the project URL looks like an archive."""
92102 return is_archive_url (self .remote )
@@ -98,16 +108,16 @@ def revision_is_enough() -> bool:
98108
99109 @staticmethod
100110 def list_tool_info () -> None :
101- """Log information about the archive fetching tool (Python's urllib )."""
102- SubProject ._log_tool ("urllib " , _ur .__doc__ or "built-in" )
111+ """Log information about the archive fetching tool (Python's http.client )."""
112+ SubProject ._log_tool ("http.client " , http . client .__doc__ or "built-in" )
103113
104114 def get_default_branch (self ) -> str :
105115 """Archives have no branches; return an empty string."""
106116 return ""
107117
108118 def _latest_revision_on_branch (self , branch : str ) -> str : # noqa: ARG002
109119 """For archives the 'latest revision' is always the URL (or hash)."""
110- return self ._project_entry . remote_url
120+ return self .remote
111121
112122 def _download_and_compute_hash (self , algorithm : str = "sha256" ) -> str :
113123 """Download the archive to a temporary file and return its hash.
@@ -117,20 +127,16 @@ def _download_and_compute_hash(self, algorithm: str = "sha256") -> str:
117127 Raises:
118128 RuntimeError: On download failure or unsupported algorithm.
119129 """
120- tmp_path : str | None = None
130+ fd , tmp_path = tempfile .mkstemp (suffix = _suffix_for_url (self .remote ))
131+ os .close (fd )
121132 try :
122- with tempfile .NamedTemporaryFile (
123- suffix = _suffix_for_url (self ._project_entry .remote_url ), delete = False
124- ) as tmp :
125- tmp_path = tmp .name
126133 self ._remote_repo .download (tmp_path )
127134 return compute_hash (tmp_path , algorithm )
128135 finally :
129- if tmp_path :
130- try :
131- os .remove (tmp_path )
132- except OSError :
133- pass
136+ try :
137+ os .remove (tmp_path )
138+ except OSError :
139+ pass
134140
135141 def _does_revision_exist (self , revision : str ) -> bool :
136142 """Check whether *revision* (a hash or URL string) is still valid.
@@ -151,17 +157,13 @@ def _does_revision_exist(self, revision: str) -> bool:
151157 except RuntimeError :
152158 return False
153159
154- # revision is the URL – just check accessibility
160+ # revision is the URL - just check accessibility
155161 return self ._remote_repo .is_accessible ()
156162
157163 def _list_of_tags (self ) -> list [str ]:
158164 """Archives have no tags; returns an empty list."""
159165 return []
160166
161- # ------------------------------------------------------------------
162- # Version overrides
163- # ------------------------------------------------------------------
164-
165167 @property
166168 def wanted_version (self ) -> Version :
167169 """Version derived from the ``integrity.hash`` field or the archive URL.
@@ -174,11 +176,7 @@ def wanted_version(self) -> Version:
174176 """
175177 if self ._project_entry .hash :
176178 return Version (revision = self ._project_entry .hash )
177- return Version (revision = self ._project_entry .remote_url )
178-
179- # ------------------------------------------------------------------
180- # Fetch
181- # ------------------------------------------------------------------
179+ return Version (revision = self .remote )
182180
183181 def _fetch_impl (self , version : Version ) -> Version :
184182 """Download and extract the archive to the local destination.
@@ -193,15 +191,12 @@ def _fetch_impl(self, version: Version) -> Version:
193191 Returns:
194192 The version that was actually fetched (hash string or URL).
195193 """
196- url = self ._project_entry .remote_url
197194 expected_hash = self ._project_entry .hash
198195
199196 pathlib .Path (self .local_path ).mkdir (parents = True , exist_ok = True )
200197
201- suffix = _suffix_for_url (url )
202- with tempfile .NamedTemporaryFile (suffix = suffix , delete = False ) as tmp :
203- tmp_path = tmp .name
204-
198+ fd , tmp_path = tempfile .mkstemp (suffix = _suffix_for_url (self .remote ))
199+ os .close (fd )
205200 try :
206201 self ._remote_repo .download (tmp_path )
207202
@@ -231,13 +226,7 @@ def _fetch_impl(self, version: Version) -> Version:
231226 except OSError :
232227 pass
233228
234- if expected_hash :
235- return Version (revision = expected_hash )
236- return Version (revision = url )
237-
238- # ------------------------------------------------------------------
239- # Freeze support
240- # ------------------------------------------------------------------
229+ return Version (revision = expected_hash if expected_hash else self .remote )
241230
242231 def freeze_project (self , project : ProjectEntry ) -> str | None :
243232 """Pin *project* to a cryptographic hash of the archive.
@@ -264,7 +253,7 @@ def freeze_project(self, project: ProjectEntry) -> str | None:
264253
265254 revision = on_disk .revision
266255
267- # Already hash-pinned – revision is "sha256:<hex>"
256+ # Already hash-pinned - revision is "sha256:<hex>"
268257 if revision .startswith (tuple (f"{ a } :" for a in SUPPORTED_HASH_ALGORITHMS )):
269258 if project .hash == revision :
270259 return None
0 commit comments