|
6 | 6 | """Test ngclient Updater top-level metadata update workflow""" |
7 | 7 |
|
8 | 8 | import builtins |
| 9 | +import datetime |
9 | 10 | import os |
10 | 11 | import sys |
11 | 12 | import tempfile |
12 | 13 | import unittest |
13 | | -from datetime import datetime, timedelta |
14 | 14 | from typing import Iterable, Optional |
15 | | -from unittest.mock import MagicMock, call, patch |
| 15 | +from unittest.mock import MagicMock, Mock, call, patch |
16 | 16 |
|
17 | 17 | from tests import utils |
18 | 18 | from tests.repository_simulator import RepositorySimulator |
@@ -43,7 +43,9 @@ class TestRefresh(unittest.TestCase): |
43 | 43 | # set dump_dir to trigger repository state dumps |
44 | 44 | dump_dir: Optional[str] = None |
45 | 45 |
|
46 | | - past_datetime = datetime.utcnow().replace(microsecond=0) - timedelta(days=5) |
| 46 | + past_datetime = datetime.datetime.utcnow().replace( |
| 47 | + microsecond=0 |
| 48 | + ) - datetime.timedelta(days=5) |
47 | 49 |
|
48 | 50 | def setUp(self) -> None: |
49 | 51 | # pylint: disable=consider-using-with |
@@ -306,6 +308,81 @@ def test_new_timestamp_unsigned(self) -> None: |
306 | 308 |
|
307 | 309 | self._assert_files_exist([Root.type]) |
308 | 310 |
|
| 311 | + @patch.object(datetime, "datetime", wraps=datetime.datetime) |
| 312 | + def test_expired_timestamp_version_rollback(self, mock_time: Mock) -> None: |
| 313 | + """Verifies that local timestamp is used in rollback checks even if it is expired. |
| 314 | +
|
| 315 | + The timestamp updates and rollback checks are performed |
| 316 | + with the following timing: |
| 317 | + - Timestamp v1 expiry set to day 7 |
| 318 | + - First updater refresh performed on day 0 |
| 319 | + - Repository publishes timestamp v2 on day 0 |
| 320 | + - Timestamp v2 expiry set to day 21 |
| 321 | + - Second updater refresh performed on day 18: |
| 322 | + assert that rollback check uses expired timestamp v1""" |
| 323 | + |
| 324 | + now = datetime.datetime.utcnow() |
| 325 | + self.sim.timestamp.expires = now + datetime.timedelta(days=7) |
| 326 | + |
| 327 | + self.sim.timestamp.version = 2 |
| 328 | + |
| 329 | + # Make a successful update of valid metadata which stores it in cache |
| 330 | + self._run_refresh() |
| 331 | + |
| 332 | + self.sim.timestamp.expires = now + datetime.timedelta(days=21) |
| 333 | + |
| 334 | + self.sim.timestamp.version = 1 |
| 335 | + |
| 336 | + mock_time.utcnow.return_value = ( |
| 337 | + datetime.datetime.utcnow() + datetime.timedelta(days=18) |
| 338 | + ) |
| 339 | + with patch("datetime.datetime", mock_time): |
| 340 | + # Check that a rollback protection is performed even if |
| 341 | + # local timestamp has expired |
| 342 | + with self.assertRaises(BadVersionNumberError): |
| 343 | + self._run_refresh() |
| 344 | + |
| 345 | + self._assert_version_equals(Timestamp.type, 2) |
| 346 | + |
| 347 | + @patch.object(datetime, "datetime", wraps=datetime.datetime) |
| 348 | + def test_expired_timestamp_snapshot_rollback(self, mock_time: Mock) -> None: |
| 349 | + """Verifies that rollback protection is done even if local timestamp has expired. |
| 350 | +
|
| 351 | + The snapshot updates and rollback protection checks are performed |
| 352 | + with the following timing: |
| 353 | + - Timestamp v1 expiry set to day 7 |
| 354 | + - Repository bumps snapshot to v3 on day 0 |
| 355 | + - First updater refresh performed on day 0 |
| 356 | + - Timestamp v2 expiry set to day 21 |
| 357 | + - Second updater refresh performed on day 18: |
| 358 | + assert that rollback protection is done with expired timestamp v1""" |
| 359 | + |
| 360 | + now = datetime.datetime.utcnow() |
| 361 | + self.sim.timestamp.expires = now + datetime.timedelta(days=7) |
| 362 | + |
| 363 | + # Bump the snapshot version number to 3 |
| 364 | + self.sim.update_snapshot() |
| 365 | + self.sim.update_snapshot() |
| 366 | + |
| 367 | + # Make a successful update of valid metadata which stores it in cache |
| 368 | + self._run_refresh() |
| 369 | + |
| 370 | + self.sim.snapshot.version = 1 |
| 371 | + # Snapshot version number is set to 2, which is still less than 3 |
| 372 | + self.sim.update_snapshot() |
| 373 | + self.sim.timestamp.expires = now + datetime.timedelta(days=21) |
| 374 | + |
| 375 | + mock_time.utcnow.return_value = ( |
| 376 | + datetime.datetime.utcnow() + datetime.timedelta(days=18) |
| 377 | + ) |
| 378 | + with patch("datetime.datetime", mock_time): |
| 379 | + # Assert that rollback protection is done even if |
| 380 | + # local timestamp has expired |
| 381 | + with self.assertRaises(BadVersionNumberError): |
| 382 | + self._run_refresh() |
| 383 | + |
| 384 | + self._assert_version_equals(Timestamp.type, 3) |
| 385 | + |
309 | 386 | def test_new_timestamp_version_rollback(self) -> None: |
310 | 387 | # Check for a rollback attack |
311 | 388 | self.sim.timestamp.version = 2 |
@@ -379,7 +456,7 @@ def test_new_snapshot_hash_mismatch(self) -> None: |
379 | 456 |
|
380 | 457 | # Modify snapshot contents without updating |
381 | 458 | # timestamp's snapshot hash |
382 | | - self.sim.snapshot.expires += timedelta(days=1) |
| 459 | + self.sim.snapshot.expires += datetime.timedelta(days=1) |
383 | 460 | self.sim.snapshot.version += 1 # snapshot v2 |
384 | 461 | self.sim.timestamp.snapshot_meta.version = self.sim.snapshot.version |
385 | 462 | self.sim.timestamp.version += 1 # timestamp v3 |
@@ -662,6 +739,44 @@ def test_load_metadata_from_cache(self, wrapped_open: MagicMock) -> None: |
662 | 739 | expected_calls = [("root", 2), ("timestamp", None)] |
663 | 740 | self.assertListEqual(self.sim.fetch_tracker.metadata, expected_calls) |
664 | 741 |
|
| 742 | + @patch.object(datetime, "datetime", wraps=datetime.datetime) |
| 743 | + def test_expired_metadata(self, mock_time: Mock) -> None: |
| 744 | + """Verifies that expired local timestamp/snapshot can be used for |
| 745 | + updating from remote. |
| 746 | +
|
| 747 | + The updates and verifications are performed with the following timing: |
| 748 | + - Timestamp v1 expiry set to day 7 |
| 749 | + - First updater refresh performed on day 0 |
| 750 | + - Repository bumps snapshot and targets to v2 on day 0 |
| 751 | + - Timestamp v2 expiry set to day 21 |
| 752 | + - Second updater refresh performed on day 18, |
| 753 | + it is successful and timestamp/snaphot final versions are v2""" |
| 754 | + |
| 755 | + now = datetime.datetime.utcnow() |
| 756 | + self.sim.timestamp.expires = now + datetime.timedelta(days=7) |
| 757 | + |
| 758 | + # Make a successful update of valid metadata which stores it in cache |
| 759 | + self._run_refresh() |
| 760 | + |
| 761 | + self.sim.targets.version += 1 |
| 762 | + self.sim.update_snapshot() |
| 763 | + self.sim.timestamp.expires = now + datetime.timedelta(days=21) |
| 764 | + |
| 765 | + # Mocking time so that local timestam has expired |
| 766 | + # but the new timestamp has not |
| 767 | + mock_time.utcnow.return_value = ( |
| 768 | + datetime.datetime.utcnow() + datetime.timedelta(days=18) |
| 769 | + ) |
| 770 | + with patch("datetime.datetime", mock_time): |
| 771 | + self._run_refresh() |
| 772 | + |
| 773 | + # Assert that the final version of timestamp/snapshot is version 2 |
| 774 | + # which means a successful refresh is performed |
| 775 | + # with expired local metadata |
| 776 | + for role in ["timestamp", "snapshot", "targets"]: |
| 777 | + md = Metadata.from_file(f"{self.metadata_dir}/{role}.json") |
| 778 | + self.assertEqual(md.signed.version, 2) |
| 779 | + |
665 | 780 |
|
666 | 781 | if __name__ == "__main__": |
667 | 782 | if "--dump" in sys.argv: |
|
0 commit comments