From aea67e998f327c6f2e2ec143c5a94b6f95b36d69 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:31:00 +0200 Subject: [PATCH] fix: claim a partial only under its own name Opening and locking a partial download are two steps. Between them another run could finish, rename the same file into place and release its lock. The second run then locked the installed BIOS, read its full size as a finished partial and truncated it to download again. After the lock the partial's name must still point at the locked file, or the run downloads into a private copy. --- install.ps1 | 2 +- install.py | 11 +++++++++++ install.sh | 2 +- tests/test_install_experience.py | 25 +++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/install.ps1 b/install.ps1 index 98b0ed7d..7903d8ff 100644 --- a/install.ps1 +++ b/install.ps1 @@ -8,7 +8,7 @@ param( $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -$defaultInstallSha256 = "1a10ac215c60706699d267fcfa31c4947d6aa792b46455e078a5861fa8c5e2ea" +$defaultInstallSha256 = "16732df165f2f3cf971ae2049aea4b89c7b6a48925d395417c9a363e9bb57970" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 66908857..364d8105 100644 --- a/install.py +++ b/install.py @@ -2604,6 +2604,17 @@ def _claim_partial(path: Path) -> BinaryIO | None: except OSError: handle.close() return None + # Opened and locked are two steps: in between, the run that held the + # partial may have renamed it into place and released it. The lock then + # sits on the installed BIOS, which looks like a finished partial and + # would be truncated. Only the file still under the partial's name is ours. + try: + ours = os.path.samestat(os.fstat(handle.fileno()), os.lstat(path)) + except OSError: + ours = False + if not ours: + handle.close() + return None return handle diff --git a/install.sh b/install.sh index 6957c00a..640614fb 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="1a10ac215c60706699d267fcfa31c4947d6aa792b46455e078a5861fa8c5e2ea" +DEFAULT_INSTALL_SHA256="16732df165f2f3cf971ae2049aea4b89c7b6a48925d395417c9a363e9bb57970" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 9eb27c3d..cb257561 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -145,6 +145,31 @@ class TwoRunsOneFolder(unittest.TestCase): self.assertIsNotNone(install._claim_partial(part)) +class APartialRenamedBeforeTheLock(unittest.TestCase): + """Open and lock are two steps. The run holding the partial could finish + in between, rename it into place and release it: the second run then + locked the installed BIOS, took it for a finished partial and truncated it.""" + + @unittest.skipIf(os.name == "nt", "flock semantics") + def test_the_installed_file_is_not_claimed(self): + import fcntl + + with tempfile.TemporaryDirectory() as tmp: + part = Path(tmp) / ".big.bin.part" + final = Path(tmp) / "big.bin" + part.write_bytes(PAYLOAD) + real_flock = fcntl.flock + + def finish_first(fd, op): + os.replace(part, final) + return real_flock(fd, op) + + with mock.patch.object(fcntl, "flock", finish_first): + claimed = install._claim_partial(part) + self.assertIsNone(claimed) + self.assertEqual(final.read_bytes(), PAYLOAD) + + class InstalledFileModes(unittest.TestCase): """A file came out 0600, the mode of the temporary it was written to: run with sudo for a root-owned BIOS folder, the emulator's own user could not