diff --git a/install.ps1 b/install.ps1 index 2165680f..5ba1d7ae 100644 --- a/install.ps1 +++ b/install.ps1 @@ -12,7 +12,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "007fffece97716aa9e1ed0606a8453f2da8a1262711aa68ca910673956b2b341" + $defaultInstallSha256 = "df46e48289c89c5c542095b01c16208ef54696df472685c99327e5022fcd7ab8" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 406d7f8d..cba224d1 100644 --- a/install.py +++ b/install.py @@ -3538,7 +3538,9 @@ def do_standalone_copies( Returns (copied, skipped, failed): skipped is a target directory that does not exist, failed a copy the system refused (permission, disk - full, read-only mount), which used to be counted as "dir not found". + full, read-only mount) or a name leading out of the folder, which used + to be counted as "dir not found". A link already at a file's name is + replaced by the file, never written through. Each failure is also appended to failures, by absolute path, for the summary to name. """ @@ -3603,14 +3605,23 @@ def do_standalone_copies( resolved_dir = target_dir.resolve() for src in sources: dest = target_dir / src.name - # A symlink already sitting at the destination would redirect - # the write outside the directory the user opted into, and a - # crafted source name would climb out of it. - if dest.is_symlink() or not _within(dest, resolved_dir): - skipped += 1 + linked = dest.is_symlink() + # A crafted source name would climb out of the directory. + if not linked and not _within(dest, resolved_dir): + print(f" ERROR: {dest}: {OUTSIDE_TARGET}") + failed += 1 + if failures is not None: + failures.append((str(dest), OUTSIDE_TARGET)) continue try: - shutil.copy2(src, dest) + if linked: + # Writing through a link already at the name would + # leave the folder the user opted into. The link + # itself gives way to the file. + _replace_with_copy(src, dest) + print(f" {dest}: link replaced by the file") + else: + shutil.copy2(src, dest) copied += 1 except OSError as exc: print(f" ERROR: {dest}: {exc.strerror or exc}") @@ -3623,6 +3634,22 @@ def do_standalone_copies( return copied, skipped, failed +OUTSIDE_TARGET = "name leads outside the emulator folder" + + +def _replace_with_copy(src: Path, dest: Path) -> None: + """Put a copy of src at dest's name without following what is there.""" + fd, scratch = tempfile.mkstemp(dir=dest.parent, prefix=f".{dest.name}.", suffix=".part") + os.close(fd) + try: + shutil.copy2(src, scratch) + os.replace(scratch, dest) + except OSError: + if os.path.lexists(scratch): + os.unlink(scratch) + raise + + def format_size(n: int) -> str: """Human-readable file size.""" if n < 1024: diff --git a/install.sh b/install.sh index 00463b67..de28778f 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="007fffece97716aa9e1ed0606a8453f2da8a1262711aa68ca910673956b2b341" +DEFAULT_INSTALL_SHA256="df46e48289c89c5c542095b01c16208ef54696df472685c99327e5022fcd7ab8" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install.py b/tests/test_install.py index a70ef8da..c1753935 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1608,6 +1608,9 @@ class TestStandaloneCopyTargetsAreUntrusted(unittest.TestCase): self.assertIsInstance(install._validate_manifest(manifest, "retroarch"), dict) def test_symlinked_destination_is_not_written_through(self): + """A link at the name was skipped in silence and counted as "dir + not found" while the folder existed: the emulator kept whatever the + link pointed at. The link now gives way to the file.""" bios = Path(_tempdir(self)) (bios / "boot.bin").write_text("rom") target = Path(_tempdir(self)) @@ -1615,12 +1618,17 @@ class TestStandaloneCopyTargetsAreUntrusted(unittest.TestCase): outside.write_text("untouched") (target / "boot.bin").symlink_to(outside) - copied, skipped, _ = install.do_standalone_copies( - self._manifest(str(target)), bios, "linux" - ) - self.assertEqual(copied, 0) - self.assertEqual(skipped, 1) + out = io.StringIO() + with contextlib.redirect_stdout(out): + copied, skipped, failed = install.do_standalone_copies( + self._manifest(str(target)), bios, "linux" + ) + self.assertEqual((copied, skipped, failed), (1, 0, 0)) self.assertEqual(outside.read_text(), "untouched") + self.assertFalse((target / "boot.bin").is_symlink()) + self.assertEqual((target / "boot.bin").read_text(), "rom") + self.assertIn("link replaced", out.getvalue()) + self.assertEqual(sorted(p.name for p in target.iterdir()), ["boot.bin"]) @unittest.skipUnless(shutil.which("pwsh"), "pwsh not available") diff --git a/wiki/installer.md b/wiki/installer.md index d736822d..862571ed 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -404,9 +404,10 @@ run rather than a file to copy. Targets are per OS, WSL falling back to the Linux ones. A directory that does not exist is skipped rather than created, so nothing is copied for an emulator -that is not installed. A destination that is already a symbolic link is skipped -too: the copy leaves the tree the user opted into, and a link there would -redirect the write somewhere else again. +that is not installed. A destination that is already a symbolic link is +replaced by the file rather than written through: a write through the link +would land outside the folder the user opted into. The summary counts each +copy as copied, skipped (folder absent) or failed, and names every failure. ## Android @@ -478,8 +479,8 @@ user's own layout, and EmuDeck links `bios/shadps4/sys_modules` into shadPS4's data directory where the emulator actually reads it. The boundary is lexical, not a resolved-path comparison, which is what keeps that case working. Standalone copies are stricter, since they write outside the tree the user -selected: a symlink already sitting at the destination is skipped rather than -followed. +selected: a symlink already sitting at the destination is replaced by the +file rather than followed. ## Changing the installer