chore: merge main into the audit 17 answers

# Conflicts:
#	install.ps1
#	install.sh
#	install/launchers/index.json
This commit is contained in:
Abdessamad Derraz committed 2026-10-27 12:48:04 +01:00
commit a67dc339bc
10 files changed
+153 -69

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "0a21af2ef15c0ff2d97e76c0d65bad4595ae9f7857c0703963e408bb0a48d3f1"
$defaultInstallSha256 = "71fa422aa38f9710c9aab81ba038cad71df66f574a527dc62e0307c69c30d1b0"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+16 -5
View File
@@ -4505,15 +4505,19 @@ def _check_holder(holder: Path) -> os.stat_result:
A link would send them wherever it points. On a system with user ids, a
folder another user owns was not made by this user's runs, and its owner
could read or swap what is moved in. Returns what was checked, so the
caller can confirm the same folder is still there once it has written
into it.
could read or swap what is moved in. A storage that keeps no owner per
file gives every file one owner: Android's shared storage shows a folder
Termux has just made as another user's, as does a FAT card. A holder
owned like the folder it sits in is that storage's owner, not someone
else's folder. Returns what was checked, so the caller can confirm the
same folder is still there once it has written into it.
"""
info = os.lstat(holder)
if not stat.S_ISDIR(info.st_mode):
raise OSError(errno.ELOOP, f"{holder} is a link or not a folder")
getuid = getattr(os, "getuid", None)
if getuid is not None and info.st_uid != getuid():
if (getuid is not None and info.st_uid != getuid()
and info.st_uid != os.lstat(holder.parent).st_uid):
raise OSError(errno.EPERM, f"{holder} belongs to another user")
return info
@@ -5575,6 +5579,12 @@ def _report_outcome(
"(or delete them), then run again.")
if any(isinstance(reason, NetworkReason) for _dest, reason in failures or []):
advice.append(NETWORK_FAILURE_ADVICE)
if any(str(reason).startswith(NOT_KEPT) for _dest, reason in failures or []):
# The same run fails the same way: "retry only those" sent a phone
# user round the loop with nothing changed.
advice.append(" The files in place were left as they were, as no backup folder could "
"be made for them. Move them out of the folder, then run again to "
"install the new ones.")
paths = paths + [folder for folder in elsewhere if folder not in paths]
if offline:
@@ -5632,7 +5642,8 @@ def _report_outcome(
print(f"Location: {_locations(paths)}")
for line in kept + advice:
print(line)
print("Run the same command again to retry only those.")
if not all(str(reason).startswith(NOT_KEPT) for _dest, reason in failures or []):
print("Run the same command again to retry only those.")
sys.exit(1)
print(f"\nDone. {_plural(downloaded, 'file')} installed, "
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="0a21af2ef15c0ff2d97e76c0d65bad4595ae9f7857c0703963e408bb0a48d3f1"
DEFAULT_INSTALL_SHA256="71fa422aa38f9710c9aab81ba038cad71df66f574a527dc62e0307c69c30d1b0"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+8 -8
View File
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
"size": 774938,
"sha256": "0a21af2ef15c0ff2d97e76c0d65bad4595ae9f7857c0703963e408bb0a48d3f1",
"size": 775811,
"sha256": "71fa422aa38f9710c9aab81ba038cad71df66f574a527dc62e0307c69c30d1b0",
"executable": false
},
{
@@ -71,8 +71,8 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
"size": 827487,
"sha256": "f10183e9aab6eb74f2e3e0cc12998af2cc53ece5cff4a1251dffa6b22c748bc3"
"size": 828360,
"sha256": "dc3204c1dab8db00fb10e47cb9d2cad8ad6320b9d5c7d8fff9168c238620d5ef"
},
"members": [
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
"size": 774938,
"sha256": "0a21af2ef15c0ff2d97e76c0d65bad4595ae9f7857c0703963e408bb0a48d3f1",
"size": 775811,
"sha256": "71fa422aa38f9710c9aab81ba038cad71df66f574a527dc62e0307c69c30d1b0",
"executable": false
},
{
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
"size": 774938,
"sha256": "0a21af2ef15c0ff2d97e76c0d65bad4595ae9f7857c0703963e408bb0a48d3f1",
"size": 775811,
"sha256": "71fa422aa38f9710c9aab81ba038cad71df66f574a527dc62e0307c69c30d1b0",
"executable": false
},
{
+4 -28
View File
@@ -43,7 +43,6 @@ import shlex
import shutil
import subprocess
import sys
import tempfile
import time
from collections.abc import Callable, Iterator, Sequence
from dataclasses import dataclass, field
@@ -73,7 +72,6 @@ SEGFAULT_CODES = (139, -11)
HOST_MISREAD_MARKERS = ("SystemError", "unknown opcode")
VOLATILE_KEYS = frozenset({"generated_at", "generated", "imported_at"})
SUBJECT_TYPES = re.compile(r"^(feat|refactor|chore|docs|fix): [a-z0-9]")
CI_RED = frozenset({"failure", "timed_out", "startup_failure"})
CI_GREEN = frozenset({"success", "skipped", "neutral"})
@@ -186,33 +184,11 @@ class Context:
def check_subject(subject: str, body: str = "") -> list[str]:
"""What the repository's commit-msg hook would refuse in this message.
"""What the repository's commit rules refuse in this message, checked
when the job list loads instead of two hours into the job."""
from commit_rules import problems # noqa: PLC0415
The hook holds the vocabulary rules, so they live in one place: a job
list is checked against it when it loads, not two hours into the job.
Without a hook (another clone) only the shape is checked.
"""
problems = []
if len(subject) > 50:
problems.append(f"subject is {len(subject)} characters, 50 at most")
if not SUBJECT_TYPES.match(subject):
problems.append("subject must read 'type: description'")
if re.search(r"^co-authored-by", body, re.IGNORECASE | re.MULTILINE):
problems.append("no Co-Authored-By trailer")
hook = subprocess.run(["git", "rev-parse", "--git-path", "hooks/commit-msg"],
capture_output=True, text=True, cwd=REPO_ROOT, check=False).stdout.strip()
hook_path = (REPO_ROOT / hook) if hook else None
if hook_path and hook_path.is_file() and os.access(hook_path, os.X_OK):
with tempfile.NamedTemporaryFile("w", suffix=".msg", delete=False) as message:
message.write(f"{subject}\n\n{body}\n" if body else f"{subject}\n")
try:
refused = subprocess.run([str(hook_path), message.name], capture_output=True,
text=True, check=False)
finally:
os.unlink(message.name)
if refused.returncode:
problems.append((refused.stderr or refused.stdout).strip() or "refused by commit-msg")
return problems
return problems(f"{subject}\n\n{body}" if body else subject)
def _step(raw: object, where: str) -> Step:
+54
View File
@@ -0,0 +1,54 @@
"""Commit message rules of this repository, in one place.
The local commit-msg hook calls this file, and autorun checks every job's
subject with it when the job list loads. Kept in the tree, the rules hold in
any clone and in CI: a copy in the hook alone left the runner checking only
the shape there.
Usage as a hook: python scripts/commit_rules.py MESSAGE_FILE
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
MAX_SUBJECT = 50
SUBJECT_TYPES = re.compile(r"^(feat|refactor|chore|docs|fix): [a-z0-9]")
REFUSED_WORDS = re.compile(
r"\b(bug|regression|revert|dead code|broken|wrong|hack|workaround|"
r"comprehensive|implement)",
re.IGNORECASE,
)
CO_AUTHOR = re.compile(r"^co-authored-by", re.IGNORECASE | re.MULTILINE)
def problems(message: str) -> list[str]:
"""What this repository refuses in a commit message, first line first."""
found = []
subject = message.split("\n", 1)[0]
if len(subject) > MAX_SUBJECT:
found.append(f"subject is {len(subject)} characters, {MAX_SUBJECT} at most: {subject}")
if not SUBJECT_TYPES.match(subject):
found.append("subject must read 'type: description' (feat, refactor, chore, docs, fix)")
text = "\n".join(line for line in message.splitlines() if not line.startswith("#"))
if REFUSED_WORDS.search(text):
found.append("the message uses a word this repository's history does not carry")
if CO_AUTHOR.search(message):
found.append("no Co-Authored-By trailer here")
return found
def main(argv: list[str]) -> int:
if len(argv) != 2:
print("usage: commit_rules.py MESSAGE_FILE", file=sys.stderr)
return 2
found = problems(Path(argv[1]).read_text(encoding="utf-8", errors="replace"))
for problem in found:
print(f"commit-msg: {problem}", file=sys.stderr)
return 1 if found else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+22 -20
View File
@@ -835,28 +835,30 @@ class BiosIntegrity(Scratch):
return str(self.tmp / "database.json")
class SubjectsFollowTheRepositoryHook(unittest.TestCase):
"""The vocabulary rules live in the commit-msg hook alone: a copy in the
runner put the very words the history refuses into a tracked file."""
class SubjectsFollowTheRepositoryRules(unittest.TestCase):
"""The commit rules live in scripts/commit_rules.py, which the local hook
calls: a copy in the hook alone let CI check only the shape."""
def test_a_refusal_comes_from_the_hook(self):
def test_the_runner_reads_the_tracked_rules(self):
import commit_rules # noqa: PLC0415
for subject in ("chore: fix the broken manifests", "update things", "chore: " + "a" * 60):
with self.subTest(subject=subject):
self.assertEqual(ar.check_subject(subject), commit_rules.problems(subject))
self.assertTrue(ar.check_subject(subject))
self.assertEqual(ar.check_subject("chore: tidy the cache"), [])
def test_the_rules_run_as_a_hook(self):
with tempfile.TemporaryDirectory() as tmp:
hook = Path(tmp) / "commit-msg"
hook.write_text('#!/bin/sh\ngrep -q forbiddenword "$1" && echo refused && exit 1\nexit 0\n')
hook.chmod(0o755)
real = subprocess.run
def fake(argv, *args, **kwargs):
if argv[:3] == ["git", "rev-parse", "--git-path"]:
return subprocess.CompletedProcess(argv, 0, stdout=str(hook) + "\n", stderr="")
return real(argv, *args, **kwargs)
with unittest.mock.patch.object(ar.subprocess, "run", side_effect=fake):
self.assertEqual(ar.check_subject("chore: tidy the cache"), [])
self.assertEqual(ar.check_subject("chore: add forbiddenword"), ["refused"])
def test_the_shape_is_checked_without_a_hook(self):
problems = ar.check_subject("not a subject that follows the type rule at all, too long")
self.assertTrue(any("50 at most" in p for p in problems))
self.assertTrue(any("type: description" in p for p in problems))
message = Path(tmp) / "MSG"
script = REPO_ROOT / "scripts" / "commit_rules.py"
for text, code in (("fix: keep the cache\n", 0), ("fix: a broken cache\n", 1),
("fix: keep it\n\nCo-Authored-By: x\n", 1)):
message.write_text(text)
done = subprocess.run([sys.executable, str(script), str(message)],
capture_output=True, text=True, check=False)
with self.subTest(text=text):
self.assertEqual(done.returncode, code, done.stderr)
if __name__ == "__main__":
+30 -5
View File
@@ -9,6 +9,7 @@ import importlib.util
import io
import json
import os
import stat
import shlex
import re
import shutil
@@ -5740,11 +5741,35 @@ class AFileYouHadIsKept(unittest.TestCase):
@unittest.skipUnless(hasattr(os, "getuid"), "user ids are POSIX")
def test_a_holder_another_user_owns_is_refused(self):
(self.home / install.BACKUP_DIR).mkdir()
with self.assertRaises(OSError):
with unittest.mock.patch.object(install.os, "getuid", return_value=os.getuid() + 1):
install._check_holder(self.home / install.BACKUP_DIR)
install._check_holder(self.home / install.BACKUP_DIR)
holder = self.home / install.BACKUP_DIR
holder.mkdir()
real = os.lstat
def planted(path, *args, **kwargs):
info = real(path, *args, **kwargs)
if Path(path) != holder:
return info
fields = list(info)
fields[stat.ST_UID] = info.st_uid + 2
return os.stat_result(fields)
with self.assertRaises(OSError), \
unittest.mock.patch.object(install.os, "lstat", side_effect=planted):
install._check_holder(holder)
install._check_holder(holder)
@unittest.skipUnless(hasattr(os, "getuid"), "user ids are POSIX")
def test_a_storage_with_one_owner_for_every_file_takes_the_holder(self):
# Android's shared storage, seen from Termux on Android 17: a folder
# the run has just made is shown as another user's, like every other
# file there. Refusing it left 25 files unreplaced on a real phone.
holder = self.home / install.BACKUP_DIR
holder.mkdir()
with unittest.mock.patch.object(install.os, "getuid", return_value=os.getuid() + 1):
install._check_holder(holder)
(self.bios / "x.bin").write_bytes(self.OLD)
self.assertTrue(self._download("x.bin")[1])
self.assertEqual((self._run_folder() / "system" / "x.bin").read_bytes(), self.OLD)
def test_restore_refuses_a_folder_the_installer_did_not_write(self):
planted = self.home / install.BACKUP_DIR / "20260101-000000"
+15
View File
@@ -337,6 +337,21 @@ class ProgressOutput(unittest.TestCase):
self.assertIn("failed: x.bin (HTTP 404)", out)
self.assertIn("2.0 KB downloaded in 0:02", out)
def test_files_not_set_aside_get_a_remedy_not_a_retry(self):
# A real Android run: 25 files whose backup folder was refused, told
# to run again, which refuses them the same way.
reason = f"{install.NOT_KEPT} (/sdcard/RetroArch/retrobios-backup belongs to another user)"
buf = io.StringIO()
with contextlib.redirect_stdout(buf), self.assertRaises(SystemExit) as end:
install._report_outcome(
[Path("/b")], 3, 0, 2, 0, False,
failures=[("a.zip", reason), ("b.zip", reason)],
)
out = buf.getvalue()
self.assertEqual(end.exception.code, 1)
self.assertIn("The files in place were left as they were", out)
self.assertNotIn("retry only those", out)
class CommandLine(unittest.TestCase):
def test_a_dry_run_writes_nothing(self):
+2 -1
View File
@@ -168,7 +168,8 @@ class NoMarketingVocabulary(unittest.TestCase):
def test_no_tracked_text_uses_them(self):
offenders = []
for path in _tracked():
if path.name == "test_prose_style.py":
# This file and the commit rules have to name the words they refuse.
if path.name in {"test_prose_style.py", "commit_rules.py"}:
continue
text = path.read_text(encoding="utf-8", errors="replace")
for number, line in enumerate(text.splitlines(), 1):