66
77The v2 format stores a JSON pointer file as a TUF target at:
88
9- targets/<project>/<version>.json (versioned)
10- targets/<project>/latest.json (latest stable, updated on each release)
9+ targets/<project>/<version>.json
1110
1211Each pointer file contains:
1312
2322The downloader TUF-verifies the pointer file, then fetches the wheel from
2423``repository + wheel_path`` and verifies the sha256 digest and byte length
2524before 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.
2640"""
2741
2842import hashlib
3145import shutil
3246import tempfile
3347import urllib .error
48+ import urllib .parse
3449import urllib .request
50+ import xml .etree .ElementTree as ET
3551from pathlib import Path
3652
53+ from packaging .version import InvalidVersion , Version
3754from tuf .ngclient import Updater
3855
3956from .exceptions import DigestMismatch , TargetNotFoundError
4057
4158logger = logging .getLogger (__name__ )
4259
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+
4386
4487class TUFPointerDownloader :
4588 """Downloads Datadog integration wheels from a v2 TUF repository."""
@@ -72,10 +115,7 @@ def __init__(
72115 self ._disable_verification = disable_verification
73116
74117 if disable_verification :
75- logger .warning (
76- 'Running with TUF verification disabled. '
77- 'Integrity is protected only by TLS (HTTPS).'
78- )
118+ logger .warning ('Running with TUF verification disabled. Integrity is protected only by TLS (HTTPS).' )
79119
80120 # ------------------------------------------------------------------
81121 # Internal helpers
@@ -101,17 +141,102 @@ def _make_updater(self, metadata_dir: Path, target_dir: Path) -> Updater:
101141 target_dir = str (target_dir ),
102142 )
103143
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+
104221 # ------------------------------------------------------------------
105222 # Public API
106223 # ------------------------------------------------------------------
107224
108225 def get_pointer (self , project : str , version : str | None = None ) -> dict :
109226 """Return the TUF-verified pointer JSON for *project* at *version*.
110227
111- *version* = None resolves to ``latest.json`` (stable releases only).
112- Raises ``TargetNotFoundError`` if the target is absent from the TUF repo.
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.
113235 """
114- target_path = f'{ project } /{ version or "latest" } .json'
236+ if version is None :
237+ version = self ._resolve_latest_version (project )
238+
239+ target_path = f'{ project } /{ version } .json'
115240
116241 with tempfile .TemporaryDirectory () as tmp :
117242 metadata_dir = Path (tmp ) / 'metadata'
@@ -126,9 +251,7 @@ def get_pointer(self, project: str, version: str | None = None) -> dict:
126251 return json .loads (resp .read ())
127252 except urllib .error .HTTPError as exc :
128253 if exc .code == 404 :
129- raise TargetNotFoundError (
130- f'Pointer not found: { target_path } '
131- ) from exc
254+ raise TargetNotFoundError (f'Pointer not found: { target_path } ' ) from exc
132255 raise
133256
134257 self ._bootstrap_metadata_dir (metadata_dir )
@@ -138,9 +261,7 @@ def get_pointer(self, project: str, version: str | None = None) -> dict:
138261
139262 target_info = updater .get_targetinfo (target_path )
140263 if target_info is None :
141- raise TargetNotFoundError (
142- f'No TUF target for { project !r} version { version or "latest" !r} '
143- )
264+ raise TargetNotFoundError (f'No TUF target for { project !r} version { version !r} ' )
144265
145266 pointer_path = target_dir / target_path
146267 pointer_path .parent .mkdir (parents = True , exist_ok = True )
0 commit comments