Skip to content

Commit 116f820

Browse files
author
Jussi Kukkonen
committed
ngclient: Add new modules
Start building the next-gen client: Copy existing components from the current client. All of these files have some changes compared to the already existing copies (because ngclient uses the same linting rules as Metadata API). * download.py is likely to see major changes in the future. * requests_fetcher is likely to see some minor changes (like allowing compression) Signed-off-by: Jussi Kukkonen <jkukkonen@vmware.com>
1 parent fc65e20 commit 116f820

3 files changed

Lines changed: 471 additions & 0 deletions

File tree

tuf/ngclient/_internal/download.py

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
#!/usr/bin/env python
2+
3+
# Copyright 2012 - 2017, New York University and the TUF contributors
4+
# SPDX-License-Identifier: MIT OR Apache-2.0
5+
6+
"""
7+
<Program Name>
8+
download.py
9+
10+
<Started>
11+
February 21, 2012. Based on previous version by Geremy Condra.
12+
13+
<Author>
14+
Konstantin Andrianov
15+
Vladimir Diaz <vladimir.v.diaz@gmail.com>
16+
17+
<Copyright>
18+
See LICENSE-MIT OR LICENSE for licensing information.
19+
20+
<Purpose>
21+
Download metadata and target files and check their validity. The hash and
22+
length of a downloaded file has to match the hash and length supplied by the
23+
metadata of that file.
24+
"""
25+
26+
import logging
27+
import tempfile
28+
import timeit
29+
from urllib import parse
30+
31+
from securesystemslib import formats as sslib_formats
32+
33+
import tuf
34+
from tuf import exceptions, formats
35+
36+
# See 'log.py' to learn how logging is handled in TUF.
37+
logger = logging.getLogger(__name__)
38+
39+
40+
def download_file(url, required_length, fetcher, strict_required_length=True):
41+
"""
42+
<Purpose>
43+
Given the url and length of the desired file, this function opens a
44+
connection to 'url' and downloads the file while ensuring its length
45+
matches 'required_length' if 'STRICT_REQUIRED_LENGH' is True (If False,
46+
the file's length is not checked and a slow retrieval exception is raised
47+
if the downloaded rate falls below the acceptable rate).
48+
49+
<Arguments>
50+
url:
51+
A URL string that represents the location of the file.
52+
53+
required_length:
54+
An integer value representing the length of the file.
55+
56+
strict_required_length:
57+
A Boolean indicator used to signal whether we should perform strict
58+
checking of required_length. True by default. We explicitly set this to
59+
False when we know that we want to turn this off for downloading the
60+
timestamp metadata, which has no signed required_length.
61+
62+
<Side Effects>
63+
A file object is created on disk to store the contents of 'url'.
64+
65+
<Exceptions>
66+
exceptions.DownloadLengthMismatchError, if there was a
67+
mismatch of observed vs expected lengths while downloading the file.
68+
69+
securesystemslib.exceptions.FormatError, if any of the arguments are
70+
improperly formatted.
71+
72+
Any other unforeseen runtime exception.
73+
74+
<Returns>
75+
A file object that points to the contents of 'url'.
76+
"""
77+
# Do all of the arguments have the appropriate format?
78+
# Raise 'securesystemslib.exceptions.FormatError' if there is a mismatch.
79+
sslib_formats.URL_SCHEMA.check_match(url)
80+
formats.LENGTH_SCHEMA.check_match(required_length)
81+
82+
# 'url.replace('\\', '/')' is needed for compatibility with Windows-based
83+
# systems, because they might use back-slashes in place of forward-slashes.
84+
# This converts it to the common format. unquote() replaces %xx escapes in
85+
# a url with their single-character equivalent. A back-slash may be
86+
# encoded as %5c in the url, which should also be replaced with a forward
87+
# slash.
88+
url = parse.unquote(url).replace("\\", "/")
89+
logger.info("Downloading: %s", url)
90+
91+
# This is the temporary file that we will return to contain the contents of
92+
# the downloaded file.
93+
temp_file = tempfile.TemporaryFile() # pylint: disable=consider-using-with
94+
95+
average_download_speed = 0
96+
number_of_bytes_received = 0
97+
98+
try:
99+
chunks = fetcher.fetch(url, required_length)
100+
start_time = timeit.default_timer()
101+
for chunk in chunks:
102+
103+
stop_time = timeit.default_timer()
104+
temp_file.write(chunk)
105+
106+
# Measure the average download speed.
107+
number_of_bytes_received += len(chunk)
108+
seconds_spent_receiving = stop_time - start_time
109+
average_download_speed = (
110+
number_of_bytes_received / seconds_spent_receiving
111+
)
112+
113+
if average_download_speed < tuf.settings.MIN_AVERAGE_DOWNLOAD_SPEED:
114+
logger.debug(
115+
"The average download speed dropped below the minimum"
116+
" average download speed set in tuf.settings.py."
117+
" Stopping the download!"
118+
)
119+
break
120+
121+
logger.debug(
122+
"The average download speed has not dipped below the"
123+
" minimum average download speed set in tuf.settings.py."
124+
)
125+
126+
# Does the total number of downloaded bytes match the required length?
127+
_check_downloaded_length(
128+
number_of_bytes_received,
129+
required_length,
130+
strict_required_length=strict_required_length,
131+
average_download_speed=average_download_speed,
132+
)
133+
134+
except Exception:
135+
# Close 'temp_file'. Any written data is lost.
136+
temp_file.close()
137+
logger.debug("Could not download URL: %s", url)
138+
raise
139+
140+
else:
141+
temp_file.seek(0)
142+
return temp_file
143+
144+
145+
def download_bytes(url, required_length, fetcher, strict_required_length=True):
146+
"""Download bytes from given url
147+
148+
Returns the downloaded bytes, otherwise like download_file()
149+
"""
150+
with download_file(
151+
url, required_length, fetcher, strict_required_length
152+
) as dl_file:
153+
return dl_file.read()
154+
155+
156+
def _check_downloaded_length(
157+
total_downloaded,
158+
required_length,
159+
strict_required_length=True,
160+
average_download_speed=None,
161+
):
162+
"""
163+
<Purpose>
164+
A helper function which checks whether the total number of downloaded
165+
bytes matches our expectation.
166+
167+
<Arguments>
168+
total_downloaded:
169+
The total number of bytes supposedly downloaded for the file in
170+
question.
171+
172+
required_length:
173+
The total number of bytes expected of the file as seen from its metadata
174+
The Timestamp role is always downloaded without a known file length, and
175+
the Root role when the client cannot download any of the required
176+
top-level roles. In both cases, 'required_length' is actually an upper
177+
limit on the length of the downloaded file.
178+
179+
strict_required_length:
180+
A Boolean indicator used to signal whether we should perform strict
181+
checking of required_length. True by default. We explicitly set this to
182+
False when we know that we want to turn this off for downloading the
183+
timestamp metadata, which has no signed required_length.
184+
185+
average_download_speed:
186+
The average download speed for the downloaded file.
187+
188+
<Side Effects>
189+
None.
190+
191+
<Exceptions>
192+
securesystemslib.exceptions.DownloadLengthMismatchError, if
193+
strict_required_length is True and total_downloaded is not equal
194+
required_length.
195+
196+
exceptions.SlowRetrievalError, if the total downloaded was
197+
done in less than the acceptable download speed (as set in
198+
tuf.settings.py).
199+
200+
<Returns>
201+
None.
202+
"""
203+
204+
if total_downloaded == required_length:
205+
logger.info("Downloaded %d bytes as expected.", total_downloaded)
206+
207+
else:
208+
# What we downloaded is not equal to the required length, but did we ask
209+
# for strict checking of required length?
210+
if strict_required_length:
211+
logger.info(
212+
"Downloaded %d bytes, but expected %d bytes",
213+
total_downloaded,
214+
required_length,
215+
)
216+
217+
# If the average download speed is below a certain threshold, we
218+
# flag this as a possible slow-retrieval attack.
219+
if average_download_speed < tuf.settings.MIN_AVERAGE_DOWNLOAD_SPEED:
220+
raise exceptions.SlowRetrievalError(average_download_speed)
221+
222+
raise exceptions.DownloadLengthMismatchError(
223+
required_length, total_downloaded
224+
)
225+
226+
# We specifically disabled strict checking of required length, but
227+
# we will log a warning anyway. This is useful when we wish to
228+
# download the Timestamp or Root metadata, for which we have no
229+
# signed metadata; so, we must guess a reasonable required_length
230+
# for it.
231+
if average_download_speed < tuf.settings.MIN_AVERAGE_DOWNLOAD_SPEED:
232+
raise exceptions.SlowRetrievalError(average_download_speed)
233+
234+
logger.debug(
235+
"Good average download speed: %f bytes per second",
236+
average_download_speed,
237+
)
238+
239+
logger.info(
240+
"Downloaded %d bytes out of upper limit of %d bytes.",
241+
total_downloaded,
242+
required_length,
243+
)

0 commit comments

Comments
 (0)