mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-27 22:27:21 -05:00
fix: keep why a data tree or a copy failed
A damaged member of a data tree's archive (zlib.error, EOFError) ended the run on a traceback, and a failed write left its .part. --offline refusing a data tree, or the download after a --from copy failed, lost its kind, so the advice named the network instead of --offline.
This commit is contained in:
1 parent
abba99d352
commit
a296bbfdff
5 files changed
+103
-32
No files matched your search
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
||||||
$defaultInstallSha256 = "7065a9d52aa9016066c273ec8a5907b09fcce7eaad5a423583c79515f09edb99"
|
$defaultInstallSha256 = "079fcba3b83003932af8f5ed513e556675832ab20e55bbbbc356581b2daf1a14"
|
||||||
$maximumInstallerBytes = 2MB
|
$maximumInstallerBytes = 2MB
|
||||||
$installerExit = 0
|
$installerExit = 0
|
||||||
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
|
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
|
||||||
|
|||||||
+54
-30
@@ -4497,7 +4497,9 @@ def _download_one(
|
|||||||
or why.startswith(NOT_KEPT)):
|
or why.startswith(NOT_KEPT)):
|
||||||
return fetched
|
return fetched
|
||||||
both = f"{result[2]}, and {why}"
|
both = f"{result[2]}, and {why}"
|
||||||
return fetched[0], False, NetworkReason(both) if isinstance(why, NetworkReason) else both
|
# The fetch's kind of failure stays, so the advice is the right one.
|
||||||
|
kind = type(why) if isinstance(why, (NetworkReason, OfflineReason)) else str
|
||||||
|
return fetched[0], False, kind(both)
|
||||||
|
|
||||||
if LOCAL.pack is not None:
|
if LOCAL.pack is not None:
|
||||||
return _extract_from_pack(f, bios_path, dest, progress)
|
return _extract_from_pack(f, bios_path, dest, progress)
|
||||||
@@ -5599,40 +5601,62 @@ def install_data_directory(entry: dict, bios_path: Path) -> dict:
|
|||||||
continue
|
continue
|
||||||
target = _destination_path(root, rel)
|
target = _destination_path(root, rel)
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
digest = hashlib.sha1()
|
outcome = _install_data_member(
|
||||||
size = 0
|
opener, meta, target, bios_path,
|
||||||
with opener() as src, tempfile.NamedTemporaryFile(
|
f"{entry['dest']}/{rel}" if entry.get("dest") else rel,
|
||||||
dir=target.parent, prefix=f".{target.name}.", suffix=".part",
|
rel, counts["not_kept"])
|
||||||
delete=False,
|
if outcome:
|
||||||
) as out:
|
counts[outcome] += 1
|
||||||
while size <= meta["size"]:
|
except _Offline as exc:
|
||||||
chunk = src.read(min(1024 * 1024, meta["size"] + 1 - size))
|
# Kept apart, so the advice is the offline one, not the network's.
|
||||||
if not chunk:
|
counts["error"] = OfflineReason(str(exc))
|
||||||
break
|
except (urllib.error.URLError, OSError, ValueError, tarfile.TarError) as exc:
|
||||||
digest.update(chunk)
|
|
||||||
size += len(chunk)
|
|
||||||
out.write(chunk)
|
|
||||||
if digest.hexdigest() == meta["sha1"] and size == meta["size"]:
|
|
||||||
slot = f"{entry['dest']}/{rel}" if entry.get("dest") else rel
|
|
||||||
try:
|
|
||||||
KEPT.keep(*_kept_slot(bios_path, slot), target,
|
|
||||||
{"sha1": meta["sha1"]})
|
|
||||||
except _NotKept as exc:
|
|
||||||
Path(out.name).unlink(missing_ok=True)
|
|
||||||
counts["not_kept"].append((rel, str(exc)))
|
|
||||||
continue
|
|
||||||
os.replace(out.name, target)
|
|
||||||
counts["installed"] += 1
|
|
||||||
else:
|
|
||||||
Path(out.name).unlink(missing_ok=True)
|
|
||||||
counts["changed"] += 1
|
|
||||||
except (urllib.error.URLError, OSError, ValueError, zipfile.BadZipFile,
|
|
||||||
tarfile.TarError) as exc:
|
|
||||||
counts["error"] = str(getattr(exc, "reason", None) or exc)
|
counts["error"] = str(getattr(exc, "reason", None) or exc)
|
||||||
|
except _MEMBER_ERRORS as exc:
|
||||||
|
# A damaged, encrypted or unsupported member, in words.
|
||||||
|
counts["error"] = unreadable_words(exc)
|
||||||
counts["absent"] = len(needed)
|
counts["absent"] = len(needed)
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def _install_data_member(
|
||||||
|
opener: Callable[[], ContextManager[BinaryIO]], meta: dict, target: Path,
|
||||||
|
bios_path: Path, slot: str, rel: str, not_kept: list[tuple[str, str]],
|
||||||
|
) -> str:
|
||||||
|
"""Write one member of a data tree's archive at target once its bytes
|
||||||
|
are the recorded ones: "installed", "changed" when they are not, or ""
|
||||||
|
when the file in place could not be moved aside (named in not_kept).
|
||||||
|
A read or write error is raised, and leaves no .part behind."""
|
||||||
|
scratch: Path | None = None
|
||||||
|
try:
|
||||||
|
digest = hashlib.sha1()
|
||||||
|
size = 0
|
||||||
|
with opener() as src, tempfile.NamedTemporaryFile(
|
||||||
|
dir=target.parent, prefix=f".{target.name}.", suffix=".part", delete=False,
|
||||||
|
) as out:
|
||||||
|
scratch = Path(out.name)
|
||||||
|
while size <= meta["size"]:
|
||||||
|
chunk = src.read(min(1024 * 1024, meta["size"] + 1 - size))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
digest.update(chunk)
|
||||||
|
size += len(chunk)
|
||||||
|
out.write(chunk)
|
||||||
|
if digest.hexdigest() != meta["sha1"] or size != meta["size"]:
|
||||||
|
return "changed"
|
||||||
|
try:
|
||||||
|
KEPT.keep(*_kept_slot(bios_path, slot), target, {"sha1": meta["sha1"]})
|
||||||
|
except _NotKept as exc:
|
||||||
|
not_kept.append((rel, str(exc)))
|
||||||
|
return ""
|
||||||
|
os.replace(scratch, target)
|
||||||
|
scratch = None
|
||||||
|
return "installed"
|
||||||
|
finally:
|
||||||
|
if scratch is not None:
|
||||||
|
scratch.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def select_data_directories(manifest: dict, files: list[dict], narrowed: bool) -> list[dict]:
|
def select_data_directories(manifest: dict, files: list[dict], narrowed: bool) -> list[dict]:
|
||||||
"""The data trees this install keeps: all, or those of a kept system."""
|
"""The data trees this install keeps: all, or those of a kept system."""
|
||||||
entries = manifest.get("data_directories") or []
|
entries = manifest.get("data_directories") or []
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ esac
|
|||||||
TEMP_INSTALLER=""
|
TEMP_INSTALLER=""
|
||||||
TEMP_DIRECTORY=""
|
TEMP_DIRECTORY=""
|
||||||
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
||||||
DEFAULT_INSTALL_SHA256="7065a9d52aa9016066c273ec8a5907b09fcce7eaad5a423583c79515f09edb99"
|
DEFAULT_INSTALL_SHA256="079fcba3b83003932af8f5ed513e556675832ab20e55bbbbc356581b2daf1a14"
|
||||||
MAX_INSTALLER_BYTES=2097152
|
MAX_INSTALLER_BYTES=2097152
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
|
|||||||
@@ -3248,6 +3248,41 @@ class DataDirectoriesAreInstalled(unittest.TestCase):
|
|||||||
self.assertEqual(counts["changed"], 1)
|
self.assertEqual(counts["changed"], 1)
|
||||||
self.assertLess(Endless.served, 64 * 1024)
|
self.assertLess(Endless.served, 64 * 1024)
|
||||||
|
|
||||||
|
def test_a_damaged_member_is_a_reason_and_leaves_no_part(self):
|
||||||
|
"""zlib.error and EOFError from a damaged member ended the run on a
|
||||||
|
traceback, and a failed write left its .part beside the tree."""
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
work = Path(_tempdir(self))
|
||||||
|
entry = self._entry()
|
||||||
|
entry["files"] = {"Sys/GC/font.bin": {"sha1": "1" * 40, "size": 4}}
|
||||||
|
for error in (zlib.error("invalid stored block lengths"), EOFError(),
|
||||||
|
OSError(28, "No space left on device")):
|
||||||
|
with self.subTest(error=type(error).__name__):
|
||||||
|
class Broken(io.RawIOBase):
|
||||||
|
def readable(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def readinto(self, buffer, error=error):
|
||||||
|
raise error
|
||||||
|
|
||||||
|
def members(_archive, _entry):
|
||||||
|
yield "Sys/GC/font.bin", 4, lambda: io.BufferedReader(Broken())
|
||||||
|
|
||||||
|
with unittest.mock.patch.object(install, "_fetch_archive",
|
||||||
|
lambda url, d: Path(d) / "a"), \
|
||||||
|
unittest.mock.patch.object(install, "_archive_members", members):
|
||||||
|
counts = install.install_data_directory(entry, work)
|
||||||
|
self.assertTrue(counts["error"])
|
||||||
|
self.assertEqual(counts["installed"], 0)
|
||||||
|
self.assertEqual([p.name for p in work.rglob("*.part")], [])
|
||||||
|
|
||||||
|
def test_offline_keeps_its_reason(self):
|
||||||
|
work = Path(_tempdir(self))
|
||||||
|
with unittest.mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}):
|
||||||
|
counts = install.install_data_directory(self._entry(), work)
|
||||||
|
self.assertIsInstance(counts["error"], install.OfflineReason)
|
||||||
|
|
||||||
def test_a_tarball_maps_through_source_path(self):
|
def test_a_tarball_maps_through_source_path(self):
|
||||||
import io as _io
|
import io as _io
|
||||||
import tarfile
|
import tarfile
|
||||||
|
|||||||
@@ -520,6 +520,18 @@ class ACopyThatFails(FromFolder):
|
|||||||
self.assertNotIn(install.NOT_WRITABLE, reason)
|
self.assertNotIn(install.NOT_WRITABLE, reason)
|
||||||
self.assertIsInstance(reason, install.NetworkReason)
|
self.assertIsInstance(reason, install.NetworkReason)
|
||||||
|
|
||||||
|
def test_offline_the_fallback_keeps_the_offline_reason(self):
|
||||||
|
"""The copy failed, the download was refused by --offline: the
|
||||||
|
joined reason lost its kind, and the advice named neither."""
|
||||||
|
_unprivileged(self)
|
||||||
|
self.source.chmod(0)
|
||||||
|
self.addCleanup(self.source.chmod, 0o644)
|
||||||
|
with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}):
|
||||||
|
_dest, ok, reason = install._download_one(self.entry, self.dest, copy=self.copy)
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertIsInstance(reason, install.OfflineReason)
|
||||||
|
self.assertIn("could not be read: Permission denied, and not fetched", reason)
|
||||||
|
|
||||||
def test_a_read_error_is_the_source_and_not_the_folder(self):
|
def test_a_read_error_is_the_source_and_not_the_folder(self):
|
||||||
class Failing(io.BytesIO):
|
class Failing(io.BytesIO):
|
||||||
def read(self, *_args):
|
def read(self, *_args):
|
||||||
|
|||||||
Reference in new issue
Block a user