From 3203c766fc09faa6a6d102d4d5d697ee532b7e35 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:37:14 +0200 Subject: [PATCH] fix: name a folder or unreadable file in place A folder or a file the user cannot read at a destination ended --check and the install on a traceback from the hashing pool. --check now gives the reason per file, and the install fails that file alone, before any download for a folder. --- install.ps1 | 2 +- install.py | 21 ++++++++++++++++++--- install.sh | 2 +- tests/test_install_selection.py | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/install.ps1 b/install.ps1 index 1ddca47e..c0e93dca 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "6d8af0dbca6697ba71692e5e482c8a16d9e8b9896652fec722d9c84f23bba047" + $defaultInstallSha256 = "c361b511d9550b2115d918a0233115cd9f42920bce881f41395c3d7614be534d" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index cb583f7f..4568f8e5 100644 --- a/install.py +++ b/install.py @@ -4046,9 +4046,18 @@ def _classify_local(entry: dict, dest: Path) -> str: for name in ("sha256", "sha1") if entry.get(name) } + if not dest.is_file(): + # A folder at the file's place: --check says so, an install fails + # on that file alone. + return "mismatched" if not expected: return "up_to_date" - actual = _digest_file(dest, tuple(expected)) + try: + actual = _digest_file(dest, tuple(expected)) + except OSError: + # Unreadable (no permission, a read error on a failing card): its + # contents are not known to be the right ones. + return "mismatched" if all(actual[name] == value for name, value in expected.items()): return "up_to_date" return "mismatched" @@ -4444,6 +4453,9 @@ def _download_one( if _write_refused(exc): return f["dest"], False, NOT_WRITABLE return f["dest"], False, exc.strerror or str(exc) + if dest.is_dir(): + # Fetched, it failed only at the rename, on an errno and two paths. + return f["dest"], False, FOLDER_IN_PLACE if copy is not None: result = _copy_local(f, bios_path, dest, copy, progress) @@ -4834,6 +4846,7 @@ READ_ONLY = "file in place is read-only" # A file in place whose content differs, which could not be moved aside: it # is left as it is rather than lost. NOT_KEPT = "file in place could not be kept aside" +FOLDER_IN_PLACE = "a folder sits where this file goes: move it away, then run again" def _clear_read_only(dest: Path) -> None: @@ -7539,12 +7552,14 @@ def _wrong_contents( owners: dict[str, list[dict]] | None = None, ) -> str: """Why the file at an entry's place is not the one the list installs.""" + if path.is_dir(): + return FOLDER_IN_PLACE try: size = path.stat().st_size with open(path, "rb") as handle: head = handle.read(4) - except OSError: - return "" + except OSError as exc: + return f"could not be read ({exc.strerror or exc}): its contents are unknown" sha1 = index.sha1(path) owner = _content_owner(sha1, entry, files, owners) if owner: diff --git a/install.sh b/install.sh index e85ff322..34c27f9d 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="6d8af0dbca6697ba71692e5e482c8a16d9e8b9896652fec722d9c84f23bba047" +DEFAULT_INSTALL_SHA256="c361b511d9550b2115d918a0233115cd9f42920bce881f41395c3d7614be534d" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install_selection.py b/tests/test_install_selection.py index 5cf8f481..3b4e2353 100644 --- a/tests/test_install_selection.py +++ b/tests/test_install_selection.py @@ -1501,5 +1501,38 @@ class TheTitleASetupPrints(unittest.TestCase): self.assertEqual(install.system_aliases(manifest)["Sony PlayStation"], "arcade") +class SomethingElseAtADestination(unittest.TestCase): + """A folder, or a file this user cannot read, at a destination ended + --check and the install on an IsADirectoryError traceback.""" + + def prepare(self, repo: FakeRepository) -> None: + (repo.dest / "scph5500.bin").mkdir(parents=True) + unreadable = repo.dest / "scph5501.bin" + unreadable.write_bytes(b"x") + unreadable.chmod(0) + + @unittest.skipIf(os.name == "nt" or (hasattr(os, "geteuid") and os.geteuid() == 0), + "a mode of 0 stops no read on Windows or as root") + def test_each_says_why_in_the_check_and_the_install(self): + with FakeRepository() as repo: + self.prepare(repo) + proc = repo.run("--check") + self.assertEqual(proc.returncode, 1, proc.stderr) + self.assertNotIn("Traceback", proc.stderr) + self.assertIn(" wrong hash: scph5500.bin\n a folder sits where this " + "file goes: move it away, then run again", proc.stdout) + self.assertIn(" wrong hash: scph5501.bin\n could not be read " + "(Permission denied)", proc.stdout) + proc = repo.run("--yes") + self.assertEqual(proc.returncode, 1, proc.stderr) + self.assertNotIn("Traceback", proc.stderr) + self.assertIn("failed: scph5500.bin (a folder sits where this file goes", + proc.stdout) + self.assertIn("failed: scph5501.bin (file in place could not be kept aside", + proc.stdout) + self.assertTrue((repo.dest / "scph5500.bin").is_dir()) + self.assertIn("gba_bios.bin", repo.installed()) + + if __name__ == "__main__": unittest.main()