Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-tuf for openSUSE:Factory checked in at 2026-10-26 10:02:49 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-tuf (Old) and /work/SRC/openSUSE:Factory/.python-tuf.new.2252680 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-tuf" Mon Oct 26 10:02:49 2026 rev:4 rq:1392537 version:7.0.1 Changes: -------- --- /work/SRC/openSUSE:Factory/python-tuf/python-tuf.changes 2026-05-20 15:26:20.130624783 +0200 +++ /work/SRC/openSUSE:Factory/.python-tuf.new.2252680/python-tuf.changes 2026-10-26 10:04:18.727558278 +0100 @@ -1,0 +2,11 @@ +Sun Oct 25 20:12:20 UTC 2026 - Dirk Müller <[email protected]> + +- update to 7.0.1: + * Fix hash method on Role and DelegatedRole + * Fix incorrect dependency range for urllib3 + * ngclient: Fix connection exception handling + * ngclient: Fix incorrect delegation in diamond-shape case + * ngclient: Avoid use of symlink on Windows: it requires + privileges + +------------------------------------------------------------------- Old: ---- tuf-7.0.0.tar.gz New: ---- tuf-7.0.1.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-tuf.spec ++++++ --- /var/tmp/diff_new_pack.1P0FvJ/_old 2026-10-26 10:04:20.415629042 +0100 +++ /var/tmp/diff_new_pack.1P0FvJ/_new 2026-10-26 10:04:20.421629294 +0100 @@ -17,7 +17,7 @@ Name: python-tuf -Version: 7.0.0 +Version: 7.0.1 Release: 0 Summary: A secure updater framework for Python License: MIT ++++++ tuf-7.0.0.tar.gz -> tuf-7.0.1.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/.github/workflows/_test.yml new/python-tuf-7.0.1/.github/workflows/_test.yml --- old/python-tuf-7.0.0/.github/workflows/_test.yml 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/.github/workflows/_test.yml 2026-09-01 13:32:56.000000000 +0200 @@ -11,12 +11,12 @@ steps: - name: Checkout TUF - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python (oldest supported version) - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.10" cache: 'pip' @@ -50,12 +50,12 @@ steps: - name: Checkout TUF - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} cache: 'pip' @@ -67,9 +67,38 @@ run: | python3 -m pip install --constraint requirements/build.txt tox coveralls - - name: Run tox + - name: Run tox (non-Windows) + if: runner.os != 'Windows' run: tox -e py + - name: Run tox on Windows (unprivileged user without Developer Mode) + if: runner.os == 'Windows' + shell: powershell + run: | + # Disable Developer Mode in registry + Set-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" -Name "AllowDevelopmentWithoutDevLicense" -Value 0 -ErrorAction SilentlyContinue + + # Create a non-admin local user + $secpasswd = ConvertTo-SecureString "Password123!" -AsPlainText -Force + $user = New-LocalUser -Name "testuser" -Password $secpasswd -FullName "Test User" -ErrorAction SilentlyContinue + + # Grant testuser permission to workspace directory and ancestors + $curr = Get-Item $pwd + while ($curr -ne $null) { + $acl = Get-Acl $curr.FullName + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule("testuser", "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow") + $acl.AddAccessRule($rule) + Set-Acl $curr.FullName $acl + $curr = $curr.Parent + } + + # Run tox as unprivileged user + $cred = New-Object System.Management.Automation.PSCredential ("testuser", $secpasswd) + $process = Start-Process -FilePath "tox" -ArgumentList "-e py" -Credential $cred -NoNewWindow -Wait -PassThru + if ($process.ExitCode -ne 0) { + exit $process.ExitCode + } + - name: Publish on coveralls.io # A failure to publish coverage results on coveralls should not # be a reason for a job failure. @@ -99,7 +128,7 @@ run: touch requirements.txt - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.x' cache: 'pip' diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/.github/workflows/_test_sslib_main.yml new/python-tuf-7.0.1/.github/workflows/_test_sslib_main.yml --- old/python-tuf-7.0.0/.github/workflows/_test_sslib_main.yml 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/.github/workflows/_test_sslib_main.yml 2026-09-01 13:32:56.000000000 +0200 @@ -11,12 +11,12 @@ steps: - name: Checkout TUF - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.x' cache: 'pip' diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/.github/workflows/cd.yml new/python-tuf-7.0.1/.github/workflows/cd.yml --- old/python-tuf-7.0.0/.github/workflows/cd.yml 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/.github/workflows/cd.yml 2026-09-01 13:32:56.000000000 +0200 @@ -18,13 +18,13 @@ needs: test steps: - name: Checkout release tag - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false ref: ${{ github.event.workflow_run.head_branch }} - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.x' @@ -103,7 +103,7 @@ - name: Publish binary wheel and source tarball on PyPI # Only attempt pypi upload in upstream repository if: github.repository == 'theupdateframework/python-tuf' - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - name: Finalize GitHub release uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/.github/workflows/codeql-analysis.yml new/python-tuf-7.0.1/.github/workflows/codeql-analysis.yml --- old/python-tuf-7.0.0/.github/workflows/codeql-analysis.yml 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/.github/workflows/codeql-analysis.yml 2026-09-01 13:32:56.000000000 +0200 @@ -23,14 +23,14 @@ steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/[email protected] # zizmor: ignore[unpinned-uses] + uses: github/codeql-action/[email protected] # zizmor: ignore[unpinned-uses] with: languages: 'python' - name: Perform CodeQL Analysis - uses: github/codeql-action/[email protected] # zizmor: ignore[unpinned-uses] + uses: github/codeql-action/[email protected] # zizmor: ignore[unpinned-uses] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/.github/workflows/conformance.yml new/python-tuf-7.0.1/.github/workflows/conformance.yml --- old/python-tuf-7.0.0/.github/workflows/conformance.yml 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/.github/workflows/conformance.yml 2026-09-01 13:32:56.000000000 +0200 @@ -16,7 +16,7 @@ runs-on: ubuntu-latest steps: - name: Checkout conformance client - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/.github/workflows/dependency-review.yml new/python-tuf-7.0.1/.github/workflows/dependency-review.yml --- old/python-tuf-7.0.0/.github/workflows/dependency-review.yml 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/.github/workflows/dependency-review.yml 2026-09-01 13:32:56.000000000 +0200 @@ -16,8 +16,8 @@ runs-on: ubuntu-latest steps: - name: 'Checkout Repository' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: 'Dependency Review' - uses: actions/[email protected] # zizmor: ignore[unpinned-uses] \ No newline at end of file + uses: actions/[email protected] # zizmor: ignore[unpinned-uses] \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/.github/workflows/scorecards.yml new/python-tuf-7.0.1/.github/workflows/scorecards.yml --- old/python-tuf-7.0.0/.github/workflows/scorecards.yml 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/.github/workflows/scorecards.yml 2026-09-01 13:32:56.000000000 +0200 @@ -22,12 +22,12 @@ steps: - name: "Checkout code" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 with: results_file: results.sarif # sarif format required by upload-sarif action @@ -37,6 +37,6 @@ publish_results: true - name: "Upload to code-scanning dashboard" - uses: github/codeql-action/[email protected] # zizmor: ignore[unpinned-uses] + uses: github/codeql-action/[email protected] # zizmor: ignore[unpinned-uses] with: sarif_file: results.sarif diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/.github/workflows/specification-version-check.yml new/python-tuf-7.0.1/.github/workflows/specification-version-check.yml --- old/python-tuf-7.0.0/.github/workflows/specification-version-check.yml 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/.github/workflows/specification-version-check.yml 2026-09-01 13:32:56.000000000 +0200 @@ -14,10 +14,10 @@ outputs: version: ${{ steps.get-version.outputs.version }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.x" - id: get-version diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/docs/CHANGELOG.md new/python-tuf-7.0.1/docs/CHANGELOG.md --- old/python-tuf-7.0.0/docs/CHANGELOG.md 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/docs/CHANGELOG.md 2026-09-01 13:32:56.000000000 +0200 @@ -2,6 +2,16 @@ ## Unreleased +## v7.0.1 + +### Fixed + +* Fix hash method on Role and DelegatedRole (#2973) +* Fix incorrect dependency range for urllib3 (#2990) +* ngclient: Fix connection exception handling (#2964, #2993) +* ngclient: Fix incorrect delegation in diamond-shape case (#2978) +* ngclient: Avoid use of symlink on Windows: it requires privileges (#2981) + ## v7.0.0 This is a major release only because of a minor ngclient API tweak: there diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/docs/INSTALLATION.rst new/python-tuf-7.0.1/docs/INSTALLATION.rst --- old/python-tuf-7.0.0/docs/INSTALLATION.rst 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/docs/INSTALLATION.rst 2026-09-01 13:32:56.000000000 +0200 @@ -5,6 +5,11 @@ `PyPI <https://pypi.org/project/tuf/>`_ with `pip <https://pip.pypa.io/en/stable/>`_. +.. note:: + + Windows users: replace ``python3`` with ``python`` or use the + ``py`` launcher (e.g. ``py -m pip install tuf``). + :: python3 -m pip install tuf diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/examples/client/README.md new/python-tuf-7.0.1/examples/client/README.md --- old/python-tuf-7.0.0/examples/client/README.md 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/examples/client/README.md 2026-09-01 13:32:56.000000000 +0200 @@ -42,5 +42,5 @@ ./client --url https://jku.github.io/tuf-demo tofu # Then download example files from the repository: -./client --url https://jku.github.io/tuf-demo download demo/succinctly-delegated-1.txt +./client --url https://jku.github.io/tuf-demo download rdimitrov/artifact-example.md ``` diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/examples/manual_repo/basic_repo.py new/python-tuf-7.0.1/examples/manual_repo/basic_repo.py --- old/python-tuf-7.0.0/examples/manual_repo/basic_repo.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/examples/manual_repo/basic_repo.py 2026-09-01 13:32:56.000000000 +0200 @@ -1,3 +1,6 @@ +# Copyright 2021-2024, New York University and the TUF contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + """ A TUF repository example using the low-level TUF Metadata API. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/examples/manual_repo/hashed_bin_delegation.py new/python-tuf-7.0.1/examples/manual_repo/hashed_bin_delegation.py --- old/python-tuf-7.0.0/examples/manual_repo/hashed_bin_delegation.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/examples/manual_repo/hashed_bin_delegation.py 2026-09-01 13:32:56.000000000 +0200 @@ -1,3 +1,6 @@ +# Copyright 2021-2025, New York University and the TUF contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + """ A TUF hash bin delegation example using the low-level TUF Metadata API. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/examples/manual_repo/succinct_hash_bin_delegations.py new/python-tuf-7.0.1/examples/manual_repo/succinct_hash_bin_delegations.py --- old/python-tuf-7.0.0/examples/manual_repo/succinct_hash_bin_delegations.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/examples/manual_repo/succinct_hash_bin_delegations.py 2026-09-01 13:32:56.000000000 +0200 @@ -1,4 +1,4 @@ -# Copyright New York University and the TUF contributors +# Copyright 2022-2024, New York University and the TUF contributors # SPDX-License-Identifier: MIT OR Apache-2.0 """ A TUF succinct hash bin delegation example using the low-level TUF Metadata API. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/pyproject.toml new/python-tuf-7.0.1/pyproject.toml --- old/python-tuf-7.0.0/pyproject.toml 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/pyproject.toml 2026-09-01 13:32:56.000000000 +0200 @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling==1.29.0"] +requires = ["hatchling==1.32.0"] build-backend = "hatchling.build" [project] @@ -42,7 +42,7 @@ ] dependencies = [ "securesystemslib~=1.0", - "urllib3<3,>=1.21.1", + "urllib3~=2.0", ] dynamic = ["version"] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/requirements/build.txt new/python-tuf-7.0.1/requirements/build.txt --- old/python-tuf-7.0.0/requirements/build.txt 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/requirements/build.txt 2026-09-01 13:32:56.000000000 +0200 @@ -1,4 +1,4 @@ # The build and tox versions specified here are also used as constraints # during CI and CD Github workflows -build==1.4.4 +build==1.5.0 tox==4.1.2 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/requirements/lint.txt new/python-tuf-7.0.1/requirements/lint.txt --- old/python-tuf-7.0.0/requirements/lint.txt 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/requirements/lint.txt 2026-09-01 13:32:56.000000000 +0200 @@ -6,9 +6,9 @@ # Lint tools # (We are not so interested in the specific versions of the tools: the versions # are pinned to prevent unexpected linting failures when tools update) -ruff==0.15.12 -mypy==1.20.2 -zizmor==1.24.1 +ruff==0.16.4 +mypy==2.3.1 +zizmor==1.29.0 # Required for type stubs freezegun==1.5.5 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/requirements/main.txt new/python-tuf-7.0.1/requirements/main.txt --- old/python-tuf-7.0.0/requirements/main.txt 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/requirements/main.txt 2026-09-01 13:32:56.000000000 +0200 @@ -7,4 +7,4 @@ # triggers CI/CD builds to automatically test against updated dependencies. # securesystemslib[crypto] -urllib3 +urllib3~=2.0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/requirements/pinned.txt new/python-tuf-7.0.1/requirements/pinned.txt --- old/python-tuf-7.0.0/requirements/pinned.txt 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/requirements/pinned.txt 2026-09-01 13:32:56.000000000 +0200 @@ -4,13 +4,13 @@ # # pip-compile --output-file=requirements/pinned.txt --strip-extras requirements/main.txt # -cffi==2.0.0 +cffi==2.1.1 # via cryptography -cryptography==47.0.0 +cryptography==50.0.0 # via securesystemslib pycparser==3.0 # via cffi -securesystemslib==1.3.1 +securesystemslib==1.5.0 # via -r requirements/main.txt -urllib3==2.6.3 +urllib3==2.7.0 # via -r requirements/main.txt diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/requirements/test.txt new/python-tuf-7.0.1/requirements/test.txt --- old/python-tuf-7.0.0/requirements/test.txt 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/requirements/test.txt 2026-09-01 13:32:56.000000000 +0200 @@ -4,5 +4,5 @@ -r pinned.txt # coverage measurement -coverage[toml]==7.13.5 +coverage[toml]==7.15.4 freezegun==1.5.5 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tests/__init__.py new/python-tuf-7.0.1/tests/__init__.py --- old/python-tuf-7.0.0/tests/__init__.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tests/__init__.py 2026-09-01 13:32:56.000000000 +0200 @@ -0,0 +1,2 @@ +# Copyright 2013-2024, the TUF contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tests/generated_data/__init__.py new/python-tuf-7.0.1/tests/generated_data/__init__.py --- old/python-tuf-7.0.0/tests/generated_data/__init__.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tests/generated_data/__init__.py 2026-09-01 13:32:56.000000000 +0200 @@ -0,0 +1,2 @@ +# Copyright 2013-2022, New York University and the TUF contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tests/generated_data/generate_md.py new/python-tuf-7.0.1/tests/generated_data/generate_md.py --- old/python-tuf-7.0.0/tests/generated_data/generate_md.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tests/generated_data/generate_md.py 2026-09-01 13:32:56.000000000 +0200 @@ -1,8 +1,8 @@ -"""Script for generating new metadata files.""" - -# Copyright New York University and the TUF contributors +# Copyright 2022-2024, New York University and the TUF contributors # SPDX-License-Identifier: MIT OR Apache-2.0 +"""Script for generating new metadata files.""" + from __future__ import annotations import os diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tests/test_api.py new/python-tuf-7.0.1/tests/test_api.py --- old/python-tuf-7.0.0/tests/test_api.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tests/test_api.py 2026-09-01 13:32:56.000000000 +0200 @@ -34,6 +34,7 @@ Delegations, Metadata, MetaFile, + Role, Root, RootVerificationResult, Signature, @@ -1082,6 +1083,34 @@ self.assertFalse(role.is_delegated_path("a/non-matching path")) self.assertTrue(role.is_delegated_path("a/path")) + def test_role_and_delegated_role_hash(self) -> None: + # Role.__hash__ previously tried to hash self.keyids (a list) and + # self.unrecognized_fields (a dict), both unhashable, so hash() + # crashed with TypeError for every Role subclass. + role = Role(["keyid1", "keyid2"], 1) + self.assertIsInstance(hash(role), int) + + # equal objects must produce equal hashes (Python data model) + role2 = Role(["keyid1", "keyid2"], 1) + self.assertEqual(role, role2) + self.assertEqual(hash(role), hash(role2)) + + # DelegatedRole.__hash__ also referenced a non-existent 'path' + # attribute (the real attribute is 'paths'); verify it's hashable + # with both paths and path_hash_prefixes variants. + dr = DelegatedRole("role1", [], 1, False, ["*"], None) + self.assertIsInstance(hash(dr), int) + dr2 = DelegatedRole("role1", [], 1, False, ["*"], None) + self.assertEqual(dr, dr2) + self.assertEqual(hash(dr), hash(dr2)) + + dr_prefix = DelegatedRole("role2", [], 1, False, None, ["abc"]) + self.assertIsInstance(hash(dr_prefix), int) + + # a DelegatedRole must now actually work as a set member / dict key + role_set = {dr, dr2} + self.assertEqual(len(role_set), 1) + def test_is_delegated_role_in_succinct_roles(self) -> None: succinct_roles = SuccinctRoles([], 1, 5, "bin") false_role_name_examples = [ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tests/test_fetcher_ng.py new/python-tuf-7.0.1/tests/test_fetcher_ng.py --- old/python-tuf-7.0.0/tests/test_fetcher_ng.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tests/test_fetcher_ng.py 2026-09-01 13:32:56.000000000 +0200 @@ -122,6 +122,46 @@ next(self.fetcher.fetch(self.url)) mock_response.stream.assert_called_once() + # urllib3 raises ReadTimeoutError directly when the gap timeout expires + # mid-stream: it is a TimeoutError but not a MaxRetryError, so it is not + # covered by the MaxRetryError case above. + @patch.object(urllib3.PoolManager, "request") + def test_response_read_timeout_error(self, mock_session_get: Mock) -> None: + mock_response = Mock() + mock_response.status = 200 + attr = { + "stream.side_effect": urllib3.exceptions.ReadTimeoutError( + urllib3.connectionpool.ConnectionPool("localhost"), + "", + "Read timed out.", + ) + } + mock_response.configure_mock(**attr) + mock_session_get.return_value = mock_response + + with self.assertRaises(exceptions.SlowRetrievalError): + next(self.fetcher.fetch(self.url)) + mock_response.stream.assert_called_once() + + # The public download_* API documents DownloadError: a mid-stream timeout + # must not escape as a raw urllib3 error. + @patch.object(urllib3.PoolManager, "request") + def test_download_bytes_read_timeout(self, mock_session_get: Mock) -> None: + mock_response = Mock() + mock_response.status = 200 + attr = { + "stream.side_effect": urllib3.exceptions.ReadTimeoutError( + urllib3.connectionpool.ConnectionPool("localhost"), + "", + "Read timed out.", + ) + } + mock_response.configure_mock(**attr) + mock_session_get.return_value = mock_response + + with self.assertRaises(exceptions.SlowRetrievalError): + self.fetcher.download_bytes(self.url, self.file_length) + # Read/connect session timeout error @patch.object( urllib3.PoolManager, @@ -137,6 +177,25 @@ self.fetcher.fetch(self.url) mock_session_get.assert_called_once() + # A non-timeout connection failure (e.g. TLS error) must surface as the + # original error, not be masked by an UnboundLocalError on "response". + @patch.object( + urllib3.PoolManager, + "request", + side_effect=urllib3.exceptions.MaxRetryError( + urllib3.connectionpool.ConnectionPool("localhost"), + "", + urllib3.exceptions.SSLError("certificate verify failed"), + ), + ) + def test_session_get_ssl_error(self, mock_session_get: Mock) -> None: + with self.assertRaises(exceptions.DownloadError) as cm: + self.fetcher.fetch(self.url) + mock_session_get.assert_called_once() + self.assertIsInstance( + cm.exception.__cause__, urllib3.exceptions.MaxRetryError + ) + # Simple bytes download def test_download_bytes(self) -> None: data = self.fetcher.download_bytes(self.url, self.file_length) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tests/test_metadata_eq_.py new/python-tuf-7.0.1/tests/test_metadata_eq_.py --- old/python-tuf-7.0.0/tests/test_metadata_eq_.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tests/test_metadata_eq_.py 2026-09-01 13:32:56.000000000 +0200 @@ -1,4 +1,4 @@ -# Copyright New York University and the TUF contributors +# Copyright 2022-2025, New York University and the TUF contributors # SPDX-License-Identifier: MIT OR Apache-2.0 """Test __eq__ implementations of classes inside tuf/api/metadata.py.""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tests/test_metadata_generation.py new/python-tuf-7.0.1/tests/test_metadata_generation.py --- old/python-tuf-7.0.0/tests/test_metadata_generation.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tests/test_metadata_generation.py 2026-09-01 13:32:56.000000000 +0200 @@ -1,8 +1,8 @@ -"""Unit tests for 'tests/generated_data/generate_md.py'.""" - -# Copyright New York University and the TUF contributors +# Copyright 2022-2024, New York University and the TUF contributors # SPDX-License-Identifier: MIT OR Apache-2.0 +"""Unit tests for 'tests/generated_data/generate_md.py'.""" + import sys import unittest diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tests/test_metadata_serialization.py new/python-tuf-7.0.1/tests/test_metadata_serialization.py --- old/python-tuf-7.0.0/tests/test_metadata_serialization.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tests/test_metadata_serialization.py 2026-09-01 13:32:56.000000000 +0200 @@ -1,4 +1,4 @@ -# Copyright New York University and the TUF contributors +# Copyright 2021-2024, New York University and the TUF contributors # SPDX-License-Identifier: MIT OR Apache-2.0 """Unit tests testing tuf/api/metadata.py classes diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tests/test_trusted_metadata_set.py new/python-tuf-7.0.1/tests/test_trusted_metadata_set.py --- old/python-tuf-7.0.0/tests/test_trusted_metadata_set.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tests/test_trusted_metadata_set.py 2026-09-01 13:32:56.000000000 +0200 @@ -1,3 +1,6 @@ +# Copyright 2021-2026, the TUF contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + """Unit tests for 'tuf/ngclient/_internal/trusted_metadata_set.py'.""" from __future__ import annotations @@ -149,6 +152,19 @@ self.assertTrue(count, 6) + def test_contains(self) -> None: + self.assertFalse(self.trusted_set.contains("role1", Targets.type)) + + self.trusted_set.update_timestamp(self.metadata[Timestamp.type]) + self.trusted_set.update_snapshot(self.metadata[Snapshot.type]) + self.trusted_set.update_targets(self.metadata[Targets.type]) + self.trusted_set.update_delegated_targets( + self.metadata["role1"], "role1", Targets.type + ) + + self.assertTrue(self.trusted_set.contains("role1", Targets.type)) + self.assertFalse(self.trusted_set.contains("role1", "other-parent")) + def test_update_metadata_output(self) -> None: timestamp = self.trusted_set.update_timestamp( self.metadata["timestamp"] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tests/test_updater_consistent_snapshot.py new/python-tuf-7.0.1/tests/test_updater_consistent_snapshot.py --- old/python-tuf-7.0.0/tests/test_updater_consistent_snapshot.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tests/test_updater_consistent_snapshot.py 2026-09-01 13:32:56.000000000 +0200 @@ -126,23 +126,20 @@ ) -> None: # Test if the client fetches and stores metadata files with the # correct version prefix, depending on 'consistent_snapshot' config - try: - consistent_snapshot: bool = test_case_data["consistent_snapshot"] - exp_calls: list[Any] = test_case_data["calls"] - - self.setup_subtest(consistent_snapshot) - updater = self._init_updater() - - # cleanup fetch tracker metadata - self.sim.fetch_tracker.metadata.clear() - updater.refresh() - - # metadata files are fetched with the expected version (or None) - self.assertListEqual(self.sim.fetch_tracker.metadata, exp_calls) - # metadata files are always persisted without a version prefix - self._assert_metadata_files_exist(TOP_LEVEL_ROLE_NAMES) - finally: - self.teardown_subtest() + consistent_snapshot: bool = test_case_data["consistent_snapshot"] + exp_calls: list[Any] = test_case_data["calls"] + + self.setup_subtest(consistent_snapshot) + updater = self._init_updater() + + # cleanup fetch tracker metadata + self.sim.fetch_tracker.metadata.clear() + updater.refresh() + + # metadata files are fetched with the expected version (or None) + self.assertListEqual(self.sim.fetch_tracker.metadata, exp_calls) + # metadata files are always persisted without a version prefix + self._assert_metadata_files_exist(TOP_LEVEL_ROLE_NAMES) delegated_roles_data = { "consistent_snaphot disabled": { @@ -161,35 +158,30 @@ ) -> None: # Test if the client fetches and stores delegated metadata files with # the correct version prefix, depending on 'consistent_snapshot' config - try: - consistent_snapshot: bool = test_case_data["consistent_snapshot"] - exp_version: int | None = test_case_data["expected_version"] - rolenames = ["role1", "..", "."] - exp_calls = [(role, exp_version) for role in rolenames] - - self.setup_subtest(consistent_snapshot) - # Add new delegated targets - spec_version = ".".join(SPECIFICATION_VERSION) - for role in rolenames: - delegated_role = DelegatedRole(role, [], 1, False, ["*"], None) - targets = Targets( - 1, spec_version, self.sim.safe_expiry, {}, None - ) - self.sim.add_delegation("targets", delegated_role, targets) - self.sim.update_snapshot() - updater = self._init_updater() - updater.refresh() - - # cleanup fetch tracker metadata - self.sim.fetch_tracker.metadata.clear() - # trigger updater to fetch the delegated metadata - updater.get_targetinfo("anything") - # metadata files are fetched with the expected version (or None) - self.assertListEqual(self.sim.fetch_tracker.metadata, exp_calls) - # metadata files are always persisted without a version prefix - self._assert_metadata_files_exist(rolenames) - finally: - self.teardown_subtest() + consistent_snapshot: bool = test_case_data["consistent_snapshot"] + exp_version: int | None = test_case_data["expected_version"] + rolenames = ["role1", "..", "."] + exp_calls = [(role, exp_version) for role in rolenames] + + self.setup_subtest(consistent_snapshot) + # Add new delegated targets + spec_version = ".".join(SPECIFICATION_VERSION) + for role in rolenames: + delegated_role = DelegatedRole(role, [], 1, False, ["*"], None) + targets = Targets(1, spec_version, self.sim.safe_expiry, {}, None) + self.sim.add_delegation("targets", delegated_role, targets) + self.sim.update_snapshot() + updater = self._init_updater() + updater.refresh() + + # cleanup fetch tracker metadata + self.sim.fetch_tracker.metadata.clear() + # trigger updater to fetch the delegated metadata + updater.get_targetinfo("anything") + # metadata files are fetched with the expected version (or None) + self.assertListEqual(self.sim.fetch_tracker.metadata, exp_calls) + # metadata files are always persisted without a version prefix + self._assert_metadata_files_exist(rolenames) targets_download_data = { "consistent_snaphot disabled": { @@ -217,40 +209,37 @@ # Test if the client fetches and stores target files with # the correct hash prefix, depending on 'consistent_snapshot' # and 'prefix_targets_with_hash' config - try: - consistent_snapshot: bool = test_case_data["consistent_snapshot"] - prefix_targets_with_hash: bool = test_case_data["prefix_targets"] - hash_algo: str | None = test_case_data["hash_algo"] - targetpaths: list[str] = test_case_data["targetpaths"] - - self.setup_subtest(consistent_snapshot, prefix_targets_with_hash) - # Add targets to repository - for targetpath in targetpaths: - self.sim.targets.version += 1 - self.sim.add_target("targets", b"content", targetpath) - self.sim.update_snapshot() - - updater = self._init_updater() - updater.config.prefix_targets_with_hash = prefix_targets_with_hash - updater.refresh() - - for path in targetpaths: - info = updater.get_targetinfo(path) - assert isinstance(info, TargetFile) - updater.download_target(info) - - # target files are always persisted without hash prefix - self._assert_targets_files_exist([info.path]) - - # files are fetched with the expected hash prefix (or None) - exp_calls = [ - (path, None if not hash_algo else info.hashes[hash_algo]) - ] - - self.assertListEqual(self.sim.fetch_tracker.targets, exp_calls) - self.sim.fetch_tracker.targets.clear() - finally: - self.teardown_subtest() + consistent_snapshot: bool = test_case_data["consistent_snapshot"] + prefix_targets_with_hash: bool = test_case_data["prefix_targets"] + hash_algo: str | None = test_case_data["hash_algo"] + targetpaths: list[str] = test_case_data["targetpaths"] + + self.setup_subtest(consistent_snapshot, prefix_targets_with_hash) + # Add targets to repository + for targetpath in targetpaths: + self.sim.targets.version += 1 + self.sim.add_target("targets", b"content", targetpath) + self.sim.update_snapshot() + + updater = self._init_updater() + updater.config.prefix_targets_with_hash = prefix_targets_with_hash + updater.refresh() + + for path in targetpaths: + info = updater.get_targetinfo(path) + assert isinstance(info, TargetFile) + updater.download_target(info) + + # target files are always persisted without hash prefix + self._assert_targets_files_exist([info.path]) + + # files are fetched with the expected hash prefix (or None) + exp_calls = [ + (path, None if not hash_algo else info.hashes[hash_algo]) + ] + + self.assertListEqual(self.sim.fetch_tracker.targets, exp_calls) + self.sim.fetch_tracker.targets.clear() if __name__ == "__main__": diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tests/test_updater_delegation_graphs.py new/python-tuf-7.0.1/tests/test_updater_delegation_graphs.py --- old/python-tuf-7.0.0/tests/test_updater_delegation_graphs.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tests/test_updater_delegation_graphs.py 2026-09-01 13:32:56.000000000 +0200 @@ -264,32 +264,29 @@ """Test that delegated roles are traversed in the order of appearance in the delegator's metadata, using pre-order depth-first search""" - try: - exp_files = [*TOP_LEVEL_ROLE_NAMES, *test_data.visited_order] - exp_calls = [(role, 1) for role in test_data.visited_order] - - self._init_repo(test_data) - self.setup_subtest() - - updater = self._init_updater() - # restrict the max number of delegations to simplify the test - updater.config.max_delegations = 4 - # Call explicitly refresh to simplify the expected_calls list - updater.refresh() - self.sim.fetch_tracker.metadata.clear() - # Check that metadata dir contains only top-level roles - self._assert_files_exist(TOP_LEVEL_ROLE_NAMES) - - # Looking for a non-existing targetpath forces updater - # to visit all possible delegated roles - targetfile = updater.get_targetinfo("missingpath") - self.assertIsNone(targetfile) - # Check that the delegated roles were visited in the expected - # order and the corresponding metadata files were persisted - self.assertListEqual(self.sim.fetch_tracker.metadata, exp_calls) - self._assert_files_exist(exp_files) - finally: - self.teardown_subtest() + exp_files = [*TOP_LEVEL_ROLE_NAMES, *test_data.visited_order] + exp_calls = [(role, 1) for role in test_data.visited_order] + + self._init_repo(test_data) + self.setup_subtest() + + updater = self._init_updater() + # restrict the max number of delegations to simplify the test + updater.config.max_delegations = 4 + # Call explicitly refresh to simplify the expected_calls list + updater.refresh() + self.sim.fetch_tracker.metadata.clear() + # Check that metadata dir contains only top-level roles + self._assert_files_exist(TOP_LEVEL_ROLE_NAMES) + + # Looking for a non-existing targetpath forces updater + # to visit all possible delegated roles + targetfile = updater.get_targetinfo("missingpath") + self.assertIsNone(targetfile) + # Check that the delegated roles were visited in the expected + # order and the corresponding metadata files were persisted + self.assertListEqual(self.sim.fetch_tracker.metadata, exp_calls) + self._assert_files_exist(exp_files) invalid_metadata = { "unsigned delegated role": DelegationsTestCase( @@ -305,30 +302,73 @@ @utils.run_sub_tests_with_dataset(invalid_metadata) def test_invalid_metadata(self, test_data: DelegationsTestCase) -> None: - try: - self._init_repo(test_data) - # The invalid role is the last visited - invalid_role = test_data.visited_order[-1] - self.sim.signers[invalid_role].clear() - - self.setup_subtest() - # The invalid role metadata must not be persisted - exp_files = [*TOP_LEVEL_ROLE_NAMES, *test_data.visited_order[:-1]] - exp_calls = [(role, 1) for role in test_data.visited_order] - - updater = self._init_updater() - # Call explicitly refresh to simplify the expected_calls list - updater.refresh() - self.sim.fetch_tracker.metadata.clear() - - with self.assertRaises(UnsignedMetadataError): - updater.get_targetinfo("missingpath") - # Check that there were no visited roles after the invalid one - # and only the valid metadata files were persisted - self.assertListEqual(self.sim.fetch_tracker.metadata, exp_calls) - self._assert_files_exist(exp_files) - finally: - self.teardown_subtest() + self._init_repo(test_data) + # The invalid role is the last visited + invalid_role = test_data.visited_order[-1] + self.sim.signers[invalid_role].clear() + + self.setup_subtest() + # The invalid role metadata must not be persisted + exp_files = [*TOP_LEVEL_ROLE_NAMES, *test_data.visited_order[:-1]] + exp_calls = [(role, 1) for role in test_data.visited_order] + + updater = self._init_updater() + # Call explicitly refresh to simplify the expected_calls list + updater.refresh() + self.sim.fetch_tracker.metadata.clear() + + with self.assertRaises(UnsignedMetadataError): + updater.get_targetinfo("missingpath") + # Check that there were no visited roles after the invalid one + # and only the valid metadata files were persisted + self.assertListEqual(self.sim.fetch_tracker.metadata, exp_calls) + self._assert_files_exist(exp_files) + + def test_shared_delegation_signature_check(self) -> None: + """Test that a role delegated by multiple parents is signature-checked + for each delegator link. + """ + test_case = DelegationsTestCase( + delegations=[ + TestDelegation("targets", "parent-a", paths=["path-a"]), + TestDelegation("targets", "parent-b", paths=["path-b"]), + TestDelegation("parent-a", "release", paths=["path-a"]), + TestDelegation("parent-b", "release", paths=["path-b"]), + ], + target_files=[ + TestTarget("release", b"content-a", "path-a"), + TestTarget("release", b"malicious-content-b", "path-b"), + ], + ) + self._init_repo(test_case) + + # Client happy path: Both delegation paths work since release is signed + # by both parents + updater = self._init_updater() + target_a = updater.get_targetinfo("path-a") + self.assertIsNotNone(target_a) + target_b = updater.get_targetinfo("path-b") + self.assertIsNotNone(target_b) + + # Create release v2: only signed by parent-a + first_keyid = next(iter(self.sim.signers["release"].keys())) + self.sim.signers["release"] = { + first_keyid: self.sim.signers["release"][first_keyid] + } + self.sim.md_delegates["release"].signed.version += 1 + self.sim.update_snapshot() + + # Client step 1: Get path-a targetinfo via parent-a -> release + # This primes TrustedMetadataSet to contain release v2 + updater = self._init_updater() + target_a = updater.get_targetinfo("path-a") + self.assertIsNotNone(target_a) + + # Client step 2: Get path-b targetinfo via parent-b -> release + # Must fail because release while loaded in TrustedMetadataSet, is not signed + # by parent-b's keys + with self.assertRaises(UnsignedMetadataError): + updater.get_targetinfo("path-b") def test_safely_encoded_rolenames(self) -> None: """Test that delegated roles names are safely encoded in the filenames @@ -398,34 +438,31 @@ in the delegator's metadata, using pre-order depth-first search and that they correctly refer to the corresponding hash bin prefixes""" - try: - exp_files = [*TOP_LEVEL_ROLE_NAMES, *test_data.visited_order] - exp_calls = [(role, 1) for role in test_data.visited_order] - - self._init_repo(test_data) - self.setup_subtest() - - updater = self._init_updater() - # Call explicitly refresh to simplify the expected_calls list - updater.refresh() - self.sim.fetch_tracker.metadata.clear() - # Check that metadata dir contains only top-level roles - self._assert_files_exist(TOP_LEVEL_ROLE_NAMES) - - # Looking for a non-existing targetpath forces updater - # to visit a correspondning delegated role - targetfile = updater.get_targetinfo("missingpath") - self.assertIsNone(targetfile) - targetfile = updater.get_targetinfo("othermissingpath") - self.assertIsNone(targetfile) - targetfile = updater.get_targetinfo("thirdmissingpath") - self.assertIsNone(targetfile) - # Check that the delegated roles were visited in the expected - # order and the corresponding metadata files were persisted - self.assertListEqual(self.sim.fetch_tracker.metadata, exp_calls) - self._assert_files_exist(exp_files) - finally: - self.teardown_subtest() + exp_files = [*TOP_LEVEL_ROLE_NAMES, *test_data.visited_order] + exp_calls = [(role, 1) for role in test_data.visited_order] + + self._init_repo(test_data) + self.setup_subtest() + + updater = self._init_updater() + # Call explicitly refresh to simplify the expected_calls list + updater.refresh() + self.sim.fetch_tracker.metadata.clear() + # Check that metadata dir contains only top-level roles + self._assert_files_exist(TOP_LEVEL_ROLE_NAMES) + + # Looking for a non-existing targetpath forces updater + # to visit a correspondning delegated role + targetfile = updater.get_targetinfo("missingpath") + self.assertIsNone(targetfile) + targetfile = updater.get_targetinfo("othermissingpath") + self.assertIsNone(targetfile) + targetfile = updater.get_targetinfo("thirdmissingpath") + self.assertIsNone(targetfile) + # Check that the delegated roles were visited in the expected + # order and the corresponding metadata files were persisted + self.assertListEqual(self.sim.fetch_tracker.metadata, exp_calls) + self._assert_files_exist(exp_files) @dataclass class SuccinctRolesTestCase: @@ -482,35 +519,31 @@ # successful traversal all top level metadata files plus the expected # bin should exist locally and only one bin must be downloaded. - try: - exp_files = [*TOP_LEVEL_ROLE_NAMES, test_data.expected_target_bin] - exp_calls = [(test_data.expected_target_bin, 1)] - - self.sim = RepositorySimulator() - self.sim.add_succinct_roles("targets", test_data.bit_length, "bin") - self.sim.update_snapshot() - - self.setup_subtest() - - updater = self._init_updater() - # Call explicitly refresh to simplify the expected_calls list. - updater.refresh() - self.sim.fetch_tracker.metadata.clear() - # Check that metadata dir contains only top-level roles - self._assert_files_exist(TOP_LEVEL_ROLE_NAMES) - - # Looking for a non-existing targetpath forces updater - # to visit a corresponding delegated role. - targetfile = updater.get_targetinfo(test_data.target_path) - self.assertIsNone(targetfile) - - # Check that the delegated roles were visited in the expected - # order and the corresponding metadata files were persisted. - self.assertListEqual(self.sim.fetch_tracker.metadata, exp_calls) - self._assert_files_exist(exp_files) + exp_files = [*TOP_LEVEL_ROLE_NAMES, test_data.expected_target_bin] + exp_calls = [(test_data.expected_target_bin, 1)] + + self.sim = RepositorySimulator() + self.sim.add_succinct_roles("targets", test_data.bit_length, "bin") + self.sim.update_snapshot() + + self.setup_subtest() - finally: - self.teardown_subtest() + updater = self._init_updater() + # Call explicitly refresh to simplify the expected_calls list. + updater.refresh() + self.sim.fetch_tracker.metadata.clear() + # Check that metadata dir contains only top-level roles + self._assert_files_exist(TOP_LEVEL_ROLE_NAMES) + + # Looking for a non-existing targetpath forces updater + # to visit a corresponding delegated role. + targetfile = updater.get_targetinfo(test_data.target_path) + self.assertIsNone(targetfile) + + # Check that the delegated roles were visited in the expected + # order and the corresponding metadata files were persisted. + self.assertListEqual(self.sim.fetch_tracker.metadata, exp_calls) + self._assert_files_exist(exp_files) class TestTargetFileSearch(TestDelegations): @@ -564,29 +597,26 @@ @utils.run_sub_tests_with_dataset(targets) def test_targetfile_search(self, test_data: TargetTestCase) -> None: - try: - self.setup_subtest() - exp_files = [*TOP_LEVEL_ROLE_NAMES, *test_data.visited_order] - exp_calls = [(role, 1) for role in test_data.visited_order] - exp_target = self.sim.target_files[test_data.targetpath].target_file - - updater = self._init_updater() - # Call explicitly refresh to simplify the expected_calls list - updater.refresh() - self.sim.fetch_tracker.metadata.clear() - target = updater.get_targetinfo(test_data.targetpath) - if target is not None: - # Confirm that the expected TargetFile is found - self.assertTrue(test_data.found) - self.assertDictEqual(target.to_dict(), exp_target.to_dict()) - else: - self.assertFalse(test_data.found) - # Check that the delegated roles were visited in the expected - # order and the corresponding metadata files were persisted - self.assertListEqual(self.sim.fetch_tracker.metadata, exp_calls) - self._assert_files_exist(exp_files) - finally: - self.teardown_subtest() + self.setup_subtest() + exp_files = [*TOP_LEVEL_ROLE_NAMES, *test_data.visited_order] + exp_calls = [(role, 1) for role in test_data.visited_order] + exp_target = self.sim.target_files[test_data.targetpath].target_file + + updater = self._init_updater() + # Call explicitly refresh to simplify the expected_calls list + updater.refresh() + self.sim.fetch_tracker.metadata.clear() + target = updater.get_targetinfo(test_data.targetpath) + if target is not None: + # Confirm that the expected TargetFile is found + self.assertTrue(test_data.found) + self.assertDictEqual(target.to_dict(), exp_target.to_dict()) + else: + self.assertFalse(test_data.found) + # Check that the delegated roles were visited in the expected + # order and the corresponding metadata files were persisted + self.assertListEqual(self.sim.fetch_tracker.metadata, exp_calls) + self._assert_files_exist(exp_files) if __name__ == "__main__": diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tests/test_updater_ng.py new/python-tuf-7.0.1/tests/test_updater_ng.py --- old/python-tuf-7.0.0/tests/test_updater_ng.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tests/test_updater_ng.py 2026-09-01 13:32:56.000000000 +0200 @@ -362,6 +362,29 @@ self.assertEqual(ua[:23], "MyApp/1.2.3 python-tuf/") + @patch("os.symlink") + def test_update_root_symlink_oserror_fallback( + self, mock_symlink: MagicMock + ) -> None: + """Test fallback to os.link when os.symlink raises OSError.""" + err = OSError("A required privilege is not held by the client") + err.winerror = 1314 + mock_symlink.side_effect = err + + self.updater._update_root_symlink() + mock_symlink.assert_called_once() + + linkname = os.path.join(self.updater._dir, "root.json") + self.assertTrue(os.path.isfile(linkname)) + self.assertFalse(os.path.islink(linkname)) + + version = self.updater._trusted_set.root.version + target = os.path.join( + self.updater._dir, "root_history", f"{version}.root.json" + ) + with open(linkname, "rb") as f1, open(target, "rb") as f2: + self.assertEqual(f1.read(), f2.read()) + if __name__ == "__main__": utils.configure_test_logging(sys.argv) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tests/test_utils.py new/python-tuf-7.0.1/tests/test_utils.py --- old/python-tuf-7.0.0/tests/test_utils.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tests/test_utils.py 2026-09-01 13:32:56.000000000 +0200 @@ -43,6 +43,36 @@ sock.close() +class TestRunSubTestsWithDataset(unittest.TestCase): + def test_teardown_subtest_runs_after_failure(self) -> None: + events = [] + + class SubTestCase(unittest.TestCase): + def teardown_subtest(self) -> None: + events.append(f"cleanup:{self.case_name}") + + @utils.run_sub_tests_with_dataset( + {"failing": False, "passing": True} + ) + def test_subtests(self, succeeds: bool) -> None: + events.append(f"run:{self.case_name}") + self.assertTrue(succeeds) + + result = unittest.TestResult() + SubTestCase("test_subtests").run(result) + + self.assertEqual( + [ + "run:failing", + "cleanup:failing", + "run:passing", + "cleanup:passing", + ], + events, + ) + self.assertEqual(len(result.failures), 1) + + class TestServerProcess(unittest.TestCase): """Test functionality provided in TestServerProcess from tests/utils.py.""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tests/utils.py new/python-tuf-7.0.1/tests/utils.py --- old/python-tuf-7.0.0/tests/utils.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tests/utils.py 2026-09-01 13:32:56.000000000 +0200 @@ -64,7 +64,11 @@ with test_cls.subTest(case=case): # Save case name for future reference test_cls.case_name = case.replace(" ", "_") - function(test_cls, data) + try: + function(test_cls, data) + finally: + if hasattr(test_cls, "teardown_subtest"): + test_cls.teardown_subtest() return wrapper diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tuf/__init__.py new/python-tuf-7.0.1/tuf/__init__.py --- old/python-tuf-7.0.0/tuf/__init__.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tuf/__init__.py 2026-09-01 13:32:56.000000000 +0200 @@ -1,7 +1,7 @@ -# Copyright New York University and the TUF contributors +# Copyright 2013-2026, New York University and the TUF contributors # SPDX-License-Identifier: MIT OR Apache-2.0 """TUF.""" # This value is used in the ngclient user agent. -__version__ = "7.0.0" +__version__ = "7.0.1" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tuf/api/__init__.py new/python-tuf-7.0.1/tuf/api/__init__.py --- old/python-tuf-7.0.0/tuf/api/__init__.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tuf/api/__init__.py 2026-09-01 13:32:56.000000000 +0200 @@ -0,0 +1,2 @@ +# Copyright 2013-2020, New York University and the TUF contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tuf/api/_payload.py new/python-tuf-7.0.1/tuf/api/_payload.py --- old/python-tuf-7.0.0/tuf/api/_payload.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tuf/api/_payload.py 2026-09-01 13:32:56.000000000 +0200 @@ -1,4 +1,4 @@ -# Copyright the TUF contributors +# Copyright 2020-2026, the TUF contributors # SPDX-License-Identifier: MIT OR Apache-2.0 @@ -311,7 +311,7 @@ ) def __hash__(self) -> int: - return hash((self.keyids, self.threshold, self.unrecognized_fields)) + return hash((tuple(self.keyids), self.threshold)) @classmethod def from_dict(cls, role_dict: dict[str, Any]) -> Role: @@ -530,7 +530,7 @@ type = _ROOT - def __init__( + def __init__( # noqa: PLR0917 self, version: int | None = None, spec_version: str | None = None, @@ -1089,7 +1089,7 @@ ValueError: Invalid arguments. """ - def __init__( + def __init__( # noqa: PLR0917 self, name: str, keyids: list[str], @@ -1131,13 +1131,19 @@ ) def __hash__(self) -> int: + paths = tuple(self.paths) if self.paths is not None else None + prefixes = ( + tuple(self.path_hash_prefixes) + if self.path_hash_prefixes is not None + else None + ) return hash( ( super().__hash__(), self.name, self.terminating, - self.path, - self.path_hash_prefixes, + paths, + prefixes, ) ) @@ -1710,7 +1716,7 @@ type = _TARGETS - def __init__( + def __init__( # noqa: PLR0917 self, version: int | None = None, spec_version: str | None = None, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tuf/api/dsse.py new/python-tuf-7.0.1/tuf/api/dsse.py --- old/python-tuf-7.0.0/tuf/api/dsse.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tuf/api/dsse.py 2026-09-01 13:32:56.000000000 +0200 @@ -1,3 +1,6 @@ +# Copyright 2023-2025, New York University and the TUF contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + """Low-level TUF DSSE API. (experimental!)""" from __future__ import annotations diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tuf/api/exceptions.py new/python-tuf-7.0.1/tuf/api/exceptions.py --- old/python-tuf-7.0.0/tuf/api/exceptions.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tuf/api/exceptions.py 2026-09-01 13:32:56.000000000 +0200 @@ -1,4 +1,4 @@ -# Copyright New York University and the TUF contributors +# Copyright 2021-2025, New York University and the TUF contributors # SPDX-License-Identifier: MIT OR Apache-2.0 """ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tuf/api/metadata.py new/python-tuf-7.0.1/tuf/api/metadata.py --- old/python-tuf-7.0.0/tuf/api/metadata.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tuf/api/metadata.py 2026-09-01 13:32:56.000000000 +0200 @@ -1,4 +1,4 @@ -# Copyright New York University and the TUF contributors +# Copyright 2020-2025, New York University and the TUF contributors # SPDX-License-Identifier: MIT OR Apache-2.0 """The low-level Metadata API. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tuf/api/serialization/__init__.py new/python-tuf-7.0.1/tuf/api/serialization/__init__.py --- old/python-tuf-7.0.0/tuf/api/serialization/__init__.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tuf/api/serialization/__init__.py 2026-09-01 13:32:56.000000000 +0200 @@ -1,4 +1,4 @@ -# Copyright New York University and the TUF contributors +# Copyright 2021-2024, New York University and the TUF contributors # SPDX-License-Identifier: MIT OR Apache-2.0 """``tuf.api.serialization`` module provides abstract base classes and concrete diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tuf/api/serialization/json.py new/python-tuf-7.0.1/tuf/api/serialization/json.py --- old/python-tuf-7.0.0/tuf/api/serialization/json.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tuf/api/serialization/json.py 2026-09-01 13:32:56.000000000 +0200 @@ -1,4 +1,4 @@ -# Copyright New York University and the TUF contributors +# Copyright 2021-2025, New York University and the TUF contributors # SPDX-License-Identifier: MIT OR Apache-2.0 """``tuf.api.serialization.json`` module provides concrete implementations to diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tuf/ngclient/__init__.py new/python-tuf-7.0.1/tuf/ngclient/__init__.py --- old/python-tuf-7.0.0/tuf/ngclient/__init__.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tuf/ngclient/__init__.py 2026-09-01 13:32:56.000000000 +0200 @@ -1,4 +1,4 @@ -# Copyright New York University and the TUF contributors +# Copyright 2021-2025, New York University and the TUF contributors # SPDX-License-Identifier: MIT OR Apache-2.0 """TUF client public API.""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tuf/ngclient/_internal/__init__.py new/python-tuf-7.0.1/tuf/ngclient/_internal/__init__.py --- old/python-tuf-7.0.0/tuf/ngclient/_internal/__init__.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tuf/ngclient/_internal/__init__.py 2026-09-01 13:32:56.000000000 +0200 @@ -0,0 +1,2 @@ +# Copyright 2013-2021, New York University and the TUF contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tuf/ngclient/_internal/proxy.py new/python-tuf-7.0.1/tuf/ngclient/_internal/proxy.py --- old/python-tuf-7.0.0/tuf/ngclient/_internal/proxy.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tuf/ngclient/_internal/proxy.py 2026-09-01 13:32:56.000000000 +0200 @@ -1,4 +1,4 @@ -# Copyright New York University and the TUF contributors +# Copyright 2025, New York University and the TUF contributors # SPDX-License-Identifier: MIT OR Apache-2.0 """Proxy environment variable handling with Urllib3""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tuf/ngclient/_internal/trusted_metadata_set.py new/python-tuf-7.0.1/tuf/ngclient/_internal/trusted_metadata_set.py --- old/python-tuf-7.0.0/tuf/ngclient/_internal/trusted_metadata_set.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tuf/ngclient/_internal/trusted_metadata_set.py 2026-09-01 13:32:56.000000000 +0200 @@ -1,4 +1,4 @@ -# Copyright the TUF contributors +# Copyright 2021-2026, the TUF contributors # SPDX-License-Identifier: MIT OR Apache-2.0 """Trusted collection of client-side TUF Metadata. @@ -115,6 +115,7 @@ error type and content will contain more details. """ self._trusted_set: dict[str, Signed] = {} + self._trusted_delegations: set[tuple[str, str]] = set() self.reference_time = datetime.datetime.now(datetime.timezone.utc) if envelope_type is EnvelopeType.SIMPLE: @@ -127,6 +128,17 @@ logger.debug("Updating initial trusted root") self._load_trusted_root(root_data) + def contains(self, role: str, delegator: str) -> bool: + """Check if ``role`` is in ``TrustedMetadataSet`` and was verified + against ``delegator``. + """ + return (delegator, role) in self._trusted_delegations + + def _add(self, role: str, signed: Signed, delegator: str) -> None: + """Add role to trusted set, keep track of delegator(s)""" + self._trusted_set[role] = signed + self._trusted_delegations.add((delegator, role)) + def __getitem__(self, role: str) -> Signed: """Return current ``Signed`` for ``role``.""" return self._trusted_set[role] @@ -196,7 +208,7 @@ # Verify that new root is signed by itself new_root.verify_delegate(Root.type, new_root_bytes, new_root_signatures) - self._trusted_set[Root.type] = new_root + self._add(Root.type, new_root, Root.type) logger.debug("Updated root v%d", new_root.version) return new_root @@ -259,7 +271,7 @@ # expiry not checked to allow old timestamp to be used for rollback # protection of new timestamp: expiry is checked in update_snapshot() - self._trusted_set[Timestamp.type] = new_timestamp + self._add(Timestamp.type, new_timestamp, Root.type) logger.debug("Updated timestamp v%d", new_timestamp.version) # timestamp is loaded: raise if it is not valid _final_ timestamp @@ -346,7 +358,7 @@ # expiry not checked to allow old snapshot to be used for rollback # protection of new snapshot: it is checked when targets is updated - self._trusted_set[Snapshot.type] = new_snapshot + self._add(Snapshot.type, new_snapshot, Root.type) logger.debug("Updated snapshot v%d", new_snapshot.version) # snapshot is loaded, but we raise if it's not valid _final_ snapshot @@ -434,7 +446,7 @@ if new_delegate.is_expired(self.reference_time): raise exceptions.ExpiredMetadataError(f"New {role_name} is expired") - self._trusted_set[role_name] = new_delegate + self._add(role_name, new_delegate, delegator_name) logger.debug("Updated %s v%d", role_name, version) return new_delegate @@ -450,7 +462,7 @@ ) new_root.verify_delegate(Root.type, new_root_bytes, new_root_signatures) - self._trusted_set[Root.type] = new_root + self._add(Root.type, new_root, Root.type) logger.debug("Loaded trusted root v%d", new_root.version) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tuf/ngclient/updater.py new/python-tuf-7.0.1/tuf/ngclient/updater.py --- old/python-tuf-7.0.0/tuf/ngclient/updater.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tuf/ngclient/updater.py 2026-09-01 13:32:56.000000000 +0200 @@ -103,7 +103,7 @@ RepositoryError: Local root.json is invalid """ - def __init__( + def __init__( # noqa: PLR0917 self, metadata_dir: str, metadata_base_url: str, @@ -369,7 +369,14 @@ current = os.path.join("root_history", f"{version}.root.json") with contextlib.suppress(FileNotFoundError): os.remove(linkname) - os.symlink(current, linkname) + try: + os.symlink(current, linkname) + except OSError as e: + if getattr(e, "winerror", None) == 1314: + # Fallback for NTFS "required privilege is not held by client" + os.link(os.path.join(self._dir, current), linkname) + else: + raise def _load_root(self) -> None: """Load root metadata. @@ -462,8 +469,8 @@ def _load_targets(self, role: str, parent_role: str) -> Targets: """Load local (and if needed remote) metadata for ``role``.""" - # Avoid loading 'role' more than once during "get_targetinfo" - if role in self._trusted_set: + # Avoid loading 'role' more than once for the same parent_role + if self._trusted_set.contains(role, parent_role): return cast("Targets", self._trusted_set[role]) try: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-tuf-7.0.0/tuf/ngclient/urllib3_fetcher.py new/python-tuf-7.0.1/tuf/ngclient/urllib3_fetcher.py --- old/python-tuf-7.0.0/tuf/ngclient/urllib3_fetcher.py 2026-05-18 09:58:45.000000000 +0200 +++ new/python-tuf-7.0.1/tuf/ngclient/urllib3_fetcher.py 2026-09-01 13:32:56.000000000 +0200 @@ -82,6 +82,9 @@ except urllib3.exceptions.MaxRetryError as e: if isinstance(e.reason, urllib3.exceptions.TimeoutError): raise exceptions.SlowRetrievalError from e + # Any other reason (e.g. TLS or connection error): let the original + # error propagate instead of falling through to an unbound response. + raise if response.status >= 400: response.close() @@ -103,9 +106,16 @@ try: yield from response.stream(self.chunk_size) + except urllib3.exceptions.TimeoutError as e: + # Raised directly when the gap timeout expires mid-stream: + # ReadTimeoutError is a TimeoutError but not a MaxRetryError. + raise exceptions.SlowRetrievalError from e except urllib3.exceptions.MaxRetryError as e: if isinstance(e.reason, urllib3.exceptions.TimeoutError): raise exceptions.SlowRetrievalError from e + # Any other reason: propagate rather than ending the stream + # silently, which would look like a complete download. + raise finally: response.release_conn()
