Skip to content

Commit

Permalink
ngclient: add support for TAP 15
Browse files Browse the repository at this point in the history
As part of the implementation of TAP 15 I had to make sure that ngclient
supports target metadata files using succinct hash delegations.

Some things to keep in mind while reviewing:
- first, it should be noted that when succinct hash info is added inside
a particular delegated role this means that every possible target has
a bin assigned to it
- the "name" attribute for DelegatedRole objects has been changed to
optional inside TAP 15, because when succinct hash delegations is used
"name" becomes redundant
- I have added an additional helper function
`DelegatedRole._calculate_rolename" for easier testing
- I intentially added more tests inside the "get_rolename_dataset"
to showcase how succinct hash delegations is using binary prefix instead
of hash prefix.

Signed-off-by: Martin Vrachev <[email protected]>
  • Loading branch information
MVrachev committed Apr 12, 2022
1 parent 6bc4702 commit 44797dd
Show file tree
Hide file tree
Showing 3 changed files with 165 additions and 5 deletions.
109 changes: 109 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import tempfile
import unittest
from copy import copy
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, ClassVar, Dict

Expand Down Expand Up @@ -689,6 +690,114 @@ def test_is_delegated_role(self) -> None:
self.assertFalse(role.is_delegated_path("a/non-matching path"))
self.assertTrue(role.is_delegated_path("a/path"))

@dataclass
class TestData:
hash_prefix_len: int
lowest_binary: str
highest_binary: str
result: str

# For ease of testing we are going to use target files with short names.
get_rolename_dataset: utils.DataSet = {
"one-hash-prefix & target hash inside zero bin": TestData(
# The binary representation of all numbers starts with 0
hash_prefix_len=1,
lowest_binary="00000000", # 0
highest_binary="01111111", # 127
result="delegated_role-0",
),
"one-hash-prefix & target name inside first bin": TestData(
# The binary representation of all numbers starts with 1
hash_prefix_len=1,
lowest_binary="10000000", # 128
highest_binary="11111111", # 255
result="delegated_role-1",
),
"two-hash-prefix & target name inside zero bin": TestData(
# The binary representation of all numbers starts with 00
hash_prefix_len=2,
lowest_binary="00000000", # 0
highest_binary="00111111", # 63
result="delegated_role-0",
),
"two-hash-prefix & target name inside first bin": TestData(
# The binary representation of all numbers starts with 01
hash_prefix_len=2,
lowest_binary="01000000", # 64
highest_binary="01111111", # 127
result="delegated_role-1",
),
"two-hash-prefix & target name inside second bin": TestData(
# The binary representation of all numbers starts with 10
hash_prefix_len=2,
lowest_binary="10000000", # 128
highest_binary="10111111", # 191
result="delegated_role-2",
),
"two-hash-prefix & target name inside third bin": TestData(
# The binary representation of all numbers starts with 11
hash_prefix_len=2,
lowest_binary="11000000", # 192
highest_binary="11111111", # 255
result="delegated_role-3",
),
"three-hash-prefix & target name inside zero bin": TestData(
# The binary representation of all numbers starts with 000
hash_prefix_len=3,
lowest_binary="00000000", # 0
highest_binary="00011111", # 31
result="delegated_role-0",
),
"three-hash-prefix & target name inside second bin": TestData(
# The binary representation of all numbers starts with 010
hash_prefix_len=3,
lowest_binary="01000000", # 64
highest_binary="01011111", # 95
result="delegated_role-2",
),
"three-hash-prefix & target name inside fourth bin": TestData(
# The binary representation of all numbers starts with 100
hash_prefix_len=3,
lowest_binary="10000000", # 128
highest_binary="10011111", # 159
result="delegated_role-4",
),
"three-hash-prefix & target name inside sixth bin": TestData(
# The binary representation of all numbers starts with 110
hash_prefix_len=3,
lowest_binary="11000000", # 192
highest_binary="11011111", # 223
result="delegated_role-6",
),
}

@utils.run_sub_tests_with_dataset(get_rolename_dataset)
def test_get_rolename_with_succinct_hash_info(
self, test_data: TestData
) -> None:
r = DelegatedRole(
None,
[],
1,
False,
succinct_hash_info={
"delegation_hash_prefix_len": test_data.hash_prefix_len,
"bin_name_prefix": "delegated_role",
},
)
lowest_decimal = int(test_data.lowest_binary, 2)
highest_decimal = int(test_data.highest_binary, 2)
for target_hash_int in range(lowest_decimal, highest_decimal + 1):
# Convert target hash from decimal to a string bit representation.
hash_bits = f"{target_hash_int:08b}"
expected_rolename = test_data.result
msg = f"Error for {hash_bits=} expected {expected_rolename}"

# pylint: disable=protected-access
self.assertEqual(
r._calculate_delegation(hash_bits), expected_rolename, msg
)


# Run unit test.
if __name__ == "__main__":
Expand Down
52 changes: 52 additions & 0 deletions tuf/api/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -1409,6 +1409,58 @@ def _is_target_in_pathpattern(targetpath: str, pathpattern: str) -> bool:

return True

def _calculate_delegation(self, hash_bits_representation: str) -> str:
"""Helper function for get_rolename calculating the actual rolename.
Args:
hash_bits_representation: binary bit representation of the target
hash.
"""
if self.succinct_hash_info is None:
raise ValueError(
"succinct_hash_info must be set to calculate the delegation"
)

bit_length = self.succinct_hash_info["delegation_hash_prefix_len"]
# Get the first bit_length of bits and then cast them to decimal.
bin_number = int(hash_bits_representation[:bit_length], 2)

name_prefix = self.succinct_hash_info["bin_name_prefix"]
return f"{name_prefix}-{bin_number}"

def find_delegation(self, target_filepath: str) -> Optional[str]:
"""Find the delegated rolename based on the given ``target filepath``.
If the given ``target_filepath`` is not in any of the paths that
``DelegatedRole`` is trusted to provide it returns None.
If succinct_hash_info is used the result will be in the format
"{bin_name_prefix}-{bin_number}" where "bin_name_prefix" is information
provided by succinct_hash_info and "bin_number" is the corresponding
bin number calculated based on the ``target_filepath``.
Args:
target_filepath: URL path to a target file, relative to a base
targets URL.
"""
# Check if the given target_filepath is in any of the "paths" or
# "path_hash_prefixes" that DelegatedRole is trusted to provide.
if self.is_delegated_path(target_filepath):
return self.name

if self.succinct_hash_info is not None:
# Split hash calculation and from the rest for easier testing.
hasher = sslib_hash.digest(algorithm="sha256")
hasher.update(target_filepath.encode("utf-8"))
# Get the bit representation of the hash.
hash_bits = "".join(
f"{one_byte:08b}" for one_byte in hasher.digest()
)

return self._calculate_delegation(hash_bits)

return None

def is_delegated_path(self, target_filepath: str) -> bool:
"""Determines whether the given ``target_filepath`` is in one of
the paths that ``DelegatedRole`` is trusted to provide.
Expand Down
9 changes: 4 additions & 5 deletions tuf/ngclient/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,12 +437,11 @@ def _preorder_depth_first_walk(
# NOTE: This may be a slow operation if there are many
# delegated roles.
for child_role in targets.delegations.roles.values():
if child_role.is_delegated_path(target_filepath):
logger.debug("Adding child role %s", child_role.name)
child_name = child_role.find_delegation(target_filepath)
if child_name is not None:
logger.debug("Adding child role %s", child_name)

child_roles_to_visit.append(
(child_role.name, role_name)
)
child_roles_to_visit.append((child_name, role_name))
if child_role.terminating:
logger.debug("Not backtracking to other roles")
delegations_to_visit = []
Expand Down

0 comments on commit 44797dd

Please sign in to comment.