diff --git a/install.ps1 b/install.ps1 index 3595d3bd..d6a1be21 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 = "17ccf172256b945fe369952d10d091de79168c3be50208841c27fdc74df9fad6" +$defaultInstallSha256 = "95e9cf9c274d3ccf5a305ee4a32e74c5c173177b7cf012069835a096e7ff2b35" $maximumInstallerBytes = 2MB $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } $temporary = $null diff --git a/install.py b/install.py index 44ddb790..31dae5a9 100644 --- a/install.py +++ b/install.py @@ -23,6 +23,7 @@ import os import platform import re import shutil +import stat import sys import tempfile import threading @@ -1579,7 +1580,7 @@ def _download_one( offset = 0 if resumable: tmp_path = dest.parent / f".{dest.name}.part" - offset = tmp_path.stat().st_size if tmp_path.exists() else 0 + offset = _partial_size(tmp_path) if offset >= expected_size: tmp_path.unlink() offset = 0 @@ -1595,7 +1596,7 @@ def _download_one( f"Content-Length {raw_length} != expected {expected_size - offset}" ) if resumable: - out = open(tmp_path, "ab" if offset else "wb") + out = _open_partial(tmp_path, append=bool(offset)) else: out = tempfile.NamedTemporaryFile( mode="wb", dir=dest.parent, prefix=f".{dest.name}.", @@ -1683,6 +1684,25 @@ def _download_one( return f["dest"], False, reason +def _partial_size(path: Path) -> int: + """Bytes already held by a partial copy; anything but a plain file is dropped.""" + try: + info = path.lstat() + except FileNotFoundError: + return 0 + if stat.S_ISREG(info.st_mode): + return info.st_size + path.unlink() + return 0 + + +def _open_partial(path: Path, append: bool): + """Open the partial copy without following a link planted at its name.""" + flags = os.O_WRONLY | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0) + flags |= os.O_APPEND if append else os.O_TRUNC + return os.fdopen(os.open(path, flags, 0o644), "ab" if append else "wb") + + class _Corrupt(Exception): """The bytes arrived but are not the file the manifest names.""" diff --git a/install.sh b/install.sh index 75d4ae9a..1046d288 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="17ccf172256b945fe369952d10d091de79168c3be50208841c27fdc74df9fad6" +DEFAULT_INSTALL_SHA256="95e9cf9c274d3ccf5a305ee4a32e74c5c173177b7cf012069835a096e7ff2b35" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 383b477e..6e665388 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -102,6 +102,23 @@ class LargeFilesResume(unittest.TestCase): self.assertTrue(ok) self.assertEqual(_RangeHandler.ranges, [""]) + @unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks") + def test_a_link_at_the_partial_name_is_never_written_through(self): + with _Server(), tempfile.TemporaryDirectory() as tmp, \ + mock.patch.object(install, "RESUME_FROM_SIZE", 1024): + victim = Path(tmp) / "victim.txt" + victim.write_bytes(b"keep me") + part = Path(tmp) / "bios" / ".big.bin.part" + part.parent.mkdir() + part.symlink_to(victim) + dest, ok, _ = install._download_one(dict(ENTRY), Path(tmp) / "bios") + self.assertTrue(ok) + self.assertEqual(victim.read_bytes(), b"keep me") + final = Path(tmp) / "bios" / "big.bin" + self.assertFalse(final.is_symlink()) + self.assertEqual(final.read_bytes(), PAYLOAD) + self.assertEqual(_RangeHandler.ranges, [""]) + class RetriesWait(unittest.TestCase): def test_a_server_error_is_retried_after_the_delay_it_asks(self):