From 85c2433c985b28eb1bdf415b42f6f27e18a48d69 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:27:22 +0200 Subject: [PATCH] fix: write each export as its bytes and mode write_text translated line ends on Windows, so FirmwareDatabase.cs's CRLF would have come out CR CR LF; it replaced the published file in place, so a run stopped mid-write left it truncated; and it dropped the mode, while rocknix-systems is 100755 in ROCKNIX's tree. Files are written atomically as UTF-8 bytes, and an exporter states the mode of any file the platform does not publish as 0644. --- scripts/export_native.py | 20 +++++++++++++++---- scripts/exporter/base_exporter.py | 5 +++++ scripts/exporter/rocknix_exporter.py | 5 +++++ tests/test_exporters.py | 30 +++++++++++++++++++++++++++- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/scripts/export_native.py b/scripts/export_native.py index 2e309e64..da19165d 100644 --- a/scripts/export_native.py +++ b/scripts/export_native.py @@ -562,10 +562,7 @@ def export_platform( platform_dir = output_dir / platform _remove_stale(platform_dir, set(produced)) - for relative, content in produced.items(): - path = platform_dir / relative - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") + _write_produced(platform_dir, produced, exporter.file_modes()) changes = landed_changes(exporter, systems) applied, filled = changes.applied, changes.filled @@ -663,6 +660,21 @@ def export_platform( return True, messages +def _write_produced(root: Path, produced: dict[str, str], modes: dict[str, int]) -> None: + """Write each file as its bytes, whole or not at all, with its mode. + + write_text translated line ends on Windows, so FirmwareDatabase.cs's + CRLF would have come out CR CR LF, and a run stopped mid-write left a + truncated file in exports/. The scratch file mkstemp makes is 0600: the + mode is set from what the platform's own file has. + """ + for relative, content in produced.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + write_bytes_atomic(str(path), content.encode("utf-8")) + path.chmod(modes.get(relative, 0o644)) + + def write_report(path: Path, report: dict) -> None: """What one platform's export changed, for the page that publishes it. diff --git a/scripts/exporter/base_exporter.py b/scripts/exporter/base_exporter.py index 164e27e4..c8875836 100644 --- a/scripts/exporter/base_exporter.py +++ b/scripts/exporter/base_exporter.py @@ -126,6 +126,11 @@ class BaseExporter(ABC): """ return True + @staticmethod + def file_modes() -> dict[str, int]: + """The mode of each file the platform publishes other than 0644.""" + return {} + @staticmethod def keys_by_system() -> bool: """Whether the platform finds a file through its system's own name. diff --git a/scripts/exporter/rocknix_exporter.py b/scripts/exporter/rocknix_exporter.py index 4483308a..d1a87258 100644 --- a/scripts/exporter/rocknix_exporter.py +++ b/scripts/exporter/rocknix_exporter.py @@ -77,6 +77,11 @@ class Exporter(BatoceraExporter): def native_filename() -> str: return "rocknix-systems" + @staticmethod + def file_modes() -> dict[str, int]: + # 100755 in ROCKNIX's tree at the pinned tag (git/trees 20261001). + return {"rocknix-systems": 0o755} + @staticmethod def native_sources() -> dict[str, str]: return {"rocknix-systems": SOURCE_URL, EMULATORS_MK: EMULATORS_URL} diff --git a/tests/test_exporters.py b/tests/test_exporters.py index 15bdb97d..08099915 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -1153,7 +1153,35 @@ class AnExportLeavesNoStaleFile(unittest.TestCase): def test_the_export_clears_before_it_writes(self): source = (REPO_ROOT / "scripts" / "export_native.py").read_text() body = source[source.index("def export_platform("):] - self.assertLess(body.index("_remove_stale("), body.index(".write_text(")) + self.assertLess(body.index("_remove_stale("), body.index("_write_produced(")) + + +class AnExportIsWrittenAsItsBytes(unittest.TestCase): + """write_text translated line ends on Windows (FirmwareDatabase.cs is + CRLF, it would have become CR CR LF), replaced the published file in + place, and dropped the mode: rocknix-systems is 100755 upstream.""" + + def test_bytes_mode_and_atomicity(self): + import stat # noqa: PLC0415 + + import export_native # noqa: PLC0415 + + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: + root = Path(tmp) + export_native._write_produced(root, { + "FirmwareDatabase.cs": "a\r\nb\r\n", + "rocknix-systems": "#!/usr/bin/python\n", + }, {"rocknix-systems": 0o755}) + self.assertEqual((root / "FirmwareDatabase.cs").read_bytes(), b"a\r\nb\r\n") + mode = lambda name: stat.S_IMODE((root / name).stat().st_mode) # noqa: E731 + self.assertEqual(mode("rocknix-systems"), 0o755) + self.assertEqual(mode("FirmwareDatabase.cs"), 0o644) + self.assertEqual(sorted(p.name for p in root.iterdir()), + ["FirmwareDatabase.cs", "rocknix-systems"]) + + def test_rocknix_says_its_script_is_executable(self): + self.assertEqual(discover_exporters()["rocknix"].file_modes(), + {"rocknix-systems": 0o755}) class AnExportWithoutTruthIsRefused(unittest.TestCase):