66
77The v2 format stores a JSON pointer file as a TUF target at:
88
9- targets/<project>/<version>.json
9+ targets/<project>/<version>.json (versioned)
10+ targets/<project>/latest.json (latest stable, updated on each release)
1011
1112Each pointer file contains:
1213
2223The downloader TUF-verifies the pointer file, then fetches the wheel from
2324``repository + wheel_path`` and verifies the sha256 digest and byte length
2425before writing to disk.
25-
26- Latest-version resolution
27- -------------------------
28-
29- When ``version`` is omitted, the downloader lists the bucket prefix
30- ``targets/<project>/`` directly via S3's ``ListObjectsV2`` REST API,
31- filters keys to PEP 440 stable versions, and picks the maximum. The
32- chosen version is then fetched through TUF as usual, so the pointer file
33- the client trusts is still cryptographically verified.
34-
35- Why list S3 instead of parsing the signed targets metadata: once
36- ``path_hash_prefixes`` delegations are in use in the TUF repo, the client
37- cannot tell from the metadata alone which delegation signs the latest
38- version of a given project. A bucket listing sidesteps that — TUF still
39- authoritatively verifies whichever version is then requested.
4026"""
4127
4228import hashlib
4531import shutil
4632import tempfile
4733import urllib .error
48- import urllib .parse
4934import urllib .request
50- import xml .etree .ElementTree as ET
5135from pathlib import Path
5236
53- from packaging .version import InvalidVersion , Version
5437from tuf .ngclient import Updater
5538
5639from .exceptions import DigestMismatch , TargetNotFoundError
5740
5841logger = logging .getLogger (__name__ )
5942
60- # S3 ListObjectsV2 returns at most 1000 keys per request; pagination
61- # continues via the ``NextContinuationToken`` in the response. Constant is
62- # the AWS-imposed max, included for clarity in the URL builder below.
63- _S3_MAX_KEYS = 1000
64-
65- # All ListBucketResult elements live under this namespace. Without a
66- # namespace prefix in the XPath, ``ElementTree.find`` won't match them.
67- _S3_NS = {'s3' : 'http://s3.amazonaws.com/doc/2006-03-01/' }
68-
69-
70- def _is_stable (version_str : str ) -> bool :
71- """Return True if *version_str* is a stable PEP 440 release.
72-
73- Stable means: parses as PEP 440, not a pre-release (aN/bN/rcN), not a
74- dev release (.devN), and no local-version identifier (+local).
75- Post-releases count as stable. Mirrors the publisher's definition so
76- the two sides agree on what "stable" means.
77- """
78- try :
79- v = Version (version_str )
80- except InvalidVersion :
81- return False
82- if v .is_prerelease or v .is_devrelease :
83- return False
84- return v .local is None
85-
8643
8744class TUFPointerDownloader :
8845 """Downloads Datadog integration wheels from a v2 TUF repository."""
@@ -115,7 +72,10 @@ def __init__(
11572 self ._disable_verification = disable_verification
11673
11774 if disable_verification :
118- logger .warning ('Running with TUF verification disabled. Integrity is protected only by TLS (HTTPS).' )
75+ logger .warning (
76+ 'Running with TUF verification disabled. '
77+ 'Integrity is protected only by TLS (HTTPS).'
78+ )
11979
12080 # ------------------------------------------------------------------
12181 # Internal helpers
@@ -141,102 +101,17 @@ def _make_updater(self, metadata_dir: Path, target_dir: Path) -> Updater:
141101 target_dir = str (target_dir ),
142102 )
143103
144- def _list_s3_keys (self , prefix : str ) -> list [str ]:
145- """List every key under *prefix* in the public S3 bucket.
146-
147- Uses S3's ListObjectsV2 REST API directly so we don't depend on
148- boto3. Handles pagination via ``ContinuationToken``; returns all
149- keys under the prefix in arbitrary order (caller filters).
150-
151- Raises ``TargetNotFoundError`` on HTTP 404 — that's how a missing
152- project surfaces (S3 returns the bucket but with KeyCount=0, but a
153- wholly unreachable bucket returns 404 / 403).
154- """
155- keys : list [str ] = []
156- continuation_token : str | None = None
157- while True :
158- params = {
159- 'list-type' : '2' ,
160- 'prefix' : prefix ,
161- 'max-keys' : str (_S3_MAX_KEYS ),
162- }
163- if continuation_token is not None :
164- params ['continuation-token' ] = continuation_token
165- url = f'{ self ._repository_url } /?{ urllib .parse .urlencode (params )} '
166- try :
167- with urllib .request .urlopen (url ) as resp :
168- body = resp .read ()
169- except urllib .error .HTTPError as exc :
170- raise TargetNotFoundError (f'Cannot list { prefix } (HTTP { exc .code } )' ) from exc
171-
172- root = ET .fromstring (body )
173- for content in root .findall ('s3:Contents' , _S3_NS ):
174- key_elem = content .find ('s3:Key' , _S3_NS )
175- if key_elem is not None and key_elem .text :
176- keys .append (key_elem .text )
177-
178- truncated_elem = root .find ('s3:IsTruncated' , _S3_NS )
179- if truncated_elem is None or truncated_elem .text != 'true' :
180- break
181- token_elem = root .find ('s3:NextContinuationToken' , _S3_NS )
182- if token_elem is None or not token_elem .text :
183- # Truncated but no continuation token — treat as terminal
184- # rather than loop forever. S3 should never produce this
185- # combination, but guard anyway.
186- break
187- continuation_token = token_elem .text
188- return keys
189-
190- def _resolve_latest_version (self , project : str ) -> str :
191- """List the bucket and return the highest PEP 440 stable version
192- for *project*.
193-
194- Raises ``TargetNotFoundError`` if no stable version is published.
195- """
196- prefix = f'targets/{ project } /'
197- keys = self ._list_s3_keys (prefix )
198-
199- # Each key under the prefix is ``targets/<project>/<version>.json``.
200- # Strip the prefix and the ``.json`` suffix to recover the version
201- # string, then filter to keys that look like ``<version>.json``
202- # (no further path segments — defensive against future siblings
203- # under ``targets/<project>/`` that aren't pointer files).
204- candidates : list [Version ] = []
205- for key in keys :
206- tail = key [len (prefix ) :]
207- if '/' in tail or not tail .endswith ('.json' ):
208- continue
209- version_str = tail [: - len ('.json' )]
210- if not _is_stable (version_str ):
211- continue
212- try :
213- candidates .append (Version (version_str ))
214- except InvalidVersion :
215- continue
216-
217- if not candidates :
218- raise TargetNotFoundError (f'No stable version found under s3 prefix { prefix !r} ' )
219- return str (max (candidates ))
220-
221104 # ------------------------------------------------------------------
222105 # Public API
223106 # ------------------------------------------------------------------
224107
225108 def get_pointer (self , project : str , version : str | None = None ) -> dict :
226109 """Return the TUF-verified pointer JSON for *project* at *version*.
227110
228- *version* = None resolves to the highest PEP 440 stable version
229- published under ``targets/<project>/`` in the bucket. The chosen
230- version is then fetched through TUF, so the returned pointer is
231- still cryptographically verified.
232-
233- Raises ``TargetNotFoundError`` if the target is absent from TUF or
234- no stable version is published.
111+ *version* = None resolves to ``latest.json`` (stable releases only).
112+ Raises ``TargetNotFoundError`` if the target is absent from the TUF repo.
235113 """
236- if version is None :
237- version = self ._resolve_latest_version (project )
238-
239- target_path = f'{ project } /{ version } .json'
114+ target_path = f'{ project } /{ version or "latest" } .json'
240115
241116 with tempfile .TemporaryDirectory () as tmp :
242117 metadata_dir = Path (tmp ) / 'metadata'
@@ -251,7 +126,9 @@ def get_pointer(self, project: str, version: str | None = None) -> dict:
251126 return json .loads (resp .read ())
252127 except urllib .error .HTTPError as exc :
253128 if exc .code == 404 :
254- raise TargetNotFoundError (f'Pointer not found: { target_path } ' ) from exc
129+ raise TargetNotFoundError (
130+ f'Pointer not found: { target_path } '
131+ ) from exc
255132 raise
256133
257134 self ._bootstrap_metadata_dir (metadata_dir )
@@ -261,7 +138,9 @@ def get_pointer(self, project: str, version: str | None = None) -> dict:
261138
262139 target_info = updater .get_targetinfo (target_path )
263140 if target_info is None :
264- raise TargetNotFoundError (f'No TUF target for { project !r} version { version !r} ' )
141+ raise TargetNotFoundError (
142+ f'No TUF target for { project !r} version { version or "latest" !r} '
143+ )
265144
266145 pointer_path = target_dir / target_path
267146 pointer_path .parent .mkdir (parents = True , exist_ok = True )
0 commit comments