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. diff --git a/tests/test_updater_ng.py b/tests/test_updater_ng.py index 5fc436ba97..06ace050ac 100644 --- a/tests/test_updater_ng.py +++ b/tests/test_updater_ng.py @@ -362,6 +362,29 @@ def test_user_agent(self) -> None: 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 --git a/tuf/ngclient/updater.py b/tuf/ngclient/updater.py index a253b18d4c..9b93053464 100644 --- a/tuf/ngclient/updater.py +++ b/tuf/ngclient/updater.py @@ -369,7 +369,14 @@ 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 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.