From 29a7c5b623c2fd12d776354a094458b43c2aafad Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Fri, 7 Aug 2026 10:56:13 +0300 Subject: [PATCH 1/4] ngclient: Fix symlink usage on Windows Running ngclient on Windows (I believe NTFS only) leads to OSError: [WinError 1314] A required privilege is not held by the client symlinking is apparently a high privilege operation: let's add a fallback. Signed-off-by: Jussi Kukkonen --- tests/test_updater_ng.py | 19 +++++++++++++++++++ tuf/ngclient/updater.py | 6 +++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/test_updater_ng.py b/tests/test_updater_ng.py index 5fc436ba97..40900d817b 100644 --- a/tests/test_updater_ng.py +++ b/tests/test_updater_ng.py @@ -362,6 +362,25 @@ def test_user_agent(self) -> None: self.assertEqual(ua[:23], "MyApp/1.2.3 python-tuf/") + @patch("os.symlink", side_effect=OSError("Required privilege not held")) + def test_update_root_symlink_oserror_fallback( + self, mock_symlink: MagicMock + ) -> None: + """Test fallback to copyfile when os.symlink raises OSError.""" + 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 --git a/tuf/ngclient/updater.py b/tuf/ngclient/updater.py index a253b18d4c..0f039e32cc 100644 --- a/tuf/ngclient/updater.py +++ b/tuf/ngclient/updater.py @@ -369,7 +369,11 @@ def _update_root_symlink(self) -> None: 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: + # fallback for windows "required privilege is not held by client" + shutil.copyfile(os.path.join(self._dir, current), linkname) def _load_root(self) -> None: """Load root metadata. From 8a13de085199486ca0589255f79db8732dfa3f88 Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Mon, 10 Aug 2026 09:26:06 +0300 Subject: [PATCH 2/4] ngclient: Switch NTFS workaround to os.link * using shutil.copyfile works but tests that mock os.open start misbehaving (only on NTFS, so we don't see this on CI) * shutil.copyfile is also not atomic (something we strive for in write ops) but os.link is Use os.link() in the workaround, it seems better all around. Signed-off-by: Jussi Kukkonen --- tests/test_updater_ng.py | 2 +- tuf/ngclient/updater.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_updater_ng.py b/tests/test_updater_ng.py index 40900d817b..a5149650b1 100644 --- a/tests/test_updater_ng.py +++ b/tests/test_updater_ng.py @@ -366,7 +366,7 @@ def test_user_agent(self) -> None: def test_update_root_symlink_oserror_fallback( self, mock_symlink: MagicMock ) -> None: - """Test fallback to copyfile when os.symlink raises OSError.""" + """Test fallback to os.link when os.symlink raises OSError.""" self.updater._update_root_symlink() mock_symlink.assert_called_once() diff --git a/tuf/ngclient/updater.py b/tuf/ngclient/updater.py index 0f039e32cc..9577d53073 100644 --- a/tuf/ngclient/updater.py +++ b/tuf/ngclient/updater.py @@ -372,8 +372,8 @@ def _update_root_symlink(self) -> None: try: os.symlink(current, linkname) except OSError: - # fallback for windows "required privilege is not held by client" - shutil.copyfile(os.path.join(self._dir, current), linkname) + # Fallback for NTFS "required privilege is not held by client" + os.link(os.path.join(self._dir, current), linkname) def _load_root(self) -> None: """Load root metadata. From f7e0b45e18fe836114a9fc5737bdbb0bdc8c3193 Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Mon, 10 Aug 2026 09:43:37 +0300 Subject: [PATCH 3/4] workflows: Run Windows tests on unprivileged account Signed-off-by: Jussi Kukkonen --- .github/workflows/_test.yml | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 917ebb5e6c..3c25f5496c 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -67,9 +67,38 @@ jobs: 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. From 698a2c63d678c5a9d51e0eeb70481637a76c9c85 Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Mon, 10 Aug 2026 09:59:27 +0300 Subject: [PATCH 4/4] ngclient: Further limit the NTFS workaround Only handle the "required privilege is not held by client" error, not other OSErrors Signed-off-by: Jussi Kukkonen --- tests/test_updater_ng.py | 6 +++++- tuf/ngclient/updater.py | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/test_updater_ng.py b/tests/test_updater_ng.py index a5149650b1..06ace050ac 100644 --- a/tests/test_updater_ng.py +++ b/tests/test_updater_ng.py @@ -362,11 +362,15 @@ def test_user_agent(self) -> None: self.assertEqual(ua[:23], "MyApp/1.2.3 python-tuf/") - @patch("os.symlink", side_effect=OSError("Required privilege not held")) + @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() diff --git a/tuf/ngclient/updater.py b/tuf/ngclient/updater.py index 9577d53073..9b93053464 100644 --- a/tuf/ngclient/updater.py +++ b/tuf/ngclient/updater.py @@ -371,9 +371,12 @@ def _update_root_symlink(self) -> None: os.remove(linkname) try: os.symlink(current, linkname) - except OSError: - # Fallback for NTFS "required privilege is not held by client" - os.link(os.path.join(self._dir, 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.