From 098b2f0b70fb28d5d78aafc0b1b0978f44bdc976 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:58:40 +0200 Subject: [PATCH] fix: restore release assets through a scratch file --- scripts/generate_db.py | 22 ++++++++++++++++++++-- tests/test_large_file_cache.py | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/scripts/generate_db.py b/scripts/generate_db.py index da7e317b..a8b05bc2 100644 --- a/scripts/generate_db.py +++ b/scripts/generate_db.py @@ -15,6 +15,7 @@ import json import os import shutil import sys +import tempfile from datetime import datetime, timezone from pathlib import Path @@ -270,6 +271,24 @@ def _load_gitignored_large_files() -> set[str]: } +def _restore(cached: str, path: str) -> None: + """Copy a fetched asset to its registered path, whole or not at all. + + An interrupted copy at the final name is a truncated file the next scan + hashes and publishes; a scratch beside it is swapped in only once full. + """ + directory = os.path.dirname(path) + os.makedirs(directory, exist_ok=True) + fd, scratch = tempfile.mkstemp(dir=directory, prefix=".restore-") + os.close(fd) + try: + shutil.copy2(cached, scratch) + os.replace(scratch, path) + finally: + if os.path.exists(scratch): + os.remove(scratch) + + def _preserve_large_file_entries(files: dict, db_path: str) -> int: """Keep the entries of release assets the checkout does not hold. @@ -309,8 +328,7 @@ def _preserve_large_file_entries(files: dict, db_path: str) -> int: expected_md5=entry.get("md5", ""), ) if cached and not os.path.exists(path): - os.makedirs(os.path.dirname(path), exist_ok=True) - shutil.copy2(cached, path) + _restore(cached, path) files[sha1] = entry count += 1 return count diff --git a/tests/test_large_file_cache.py b/tests/test_large_file_cache.py index ac7a537a..ae2d0043 100644 --- a/tests/test_large_file_cache.py +++ b/tests/test_large_file_cache.py @@ -19,6 +19,7 @@ import threading import unittest import urllib.error from pathlib import Path +from unittest import mock REPO_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO_ROOT / "scripts")) @@ -296,6 +297,25 @@ class PreservedLargeFileEntries(unittest.TestCase): self.assertEqual(files["b" * 40]["path"], "bios/Sony/PS3/FW.PUP") self.assertEqual((self.tmp / "bios/Sony/PS3/FW.PUP").read_bytes(), b"firmware") + def test_an_interrupted_restore_leaves_no_file_behind(self): + cache = self.tmp / "cache" / "FW.PUP" + cache.parent.mkdir() + cache.write_bytes(b"firmware") + common.fetch_large_file = lambda *_a, **_k: str(cache) + db_path = self._write_db( + {"b" * 40: {"name": "FW.PUP", "path": "bios/Sony/PS3/FW.PUP"}} + ) + + def interrupted(_src, dst, *_a, **_k): + Path(dst).write_bytes(b"firm") + raise KeyboardInterrupt + + with mock.patch.object(self.generate_db.shutil, "copy2", interrupted), \ + self.assertRaises(KeyboardInterrupt): + self.generate_db._preserve_large_file_entries({}, db_path) + directory = self.tmp / "bios/Sony/PS3" + self.assertEqual(list(directory.iterdir()), []) + def test_a_file_sharing_only_the_name_is_not_preserved(self): common.fetch_large_file = lambda *_a, **_k: "/cache/large/FW.PUP" db_path = self._write_db(