fix: hold manifest paths inside the trust boundary

Every other manifest field was treated as hostile input, but
standalone_copies targets were only length-checked before being
expanded and written to: a traversal component or a symlink already
sitting at the destination sent the copy outside the directory the
user opted into. Targets are now validated like the other paths and a
symlinked destination is never followed.

RETROBIOS_BASE_URL serves the manifest and the files it declares, so
it now has to be HTTPS the way both bootstraps already require of the
installer URL; loopback stays open for the end-to-end tests.

install.ps1 left TLS at the Windows PowerShell 5.1 default, which
GitHub refuses, so the download failed before any hash was checked.

check_local read every file once per declared digest, single threaded.
One read now feeds both, across the same pool the downloads use.

RetroPie had no manifest, so the one-line installer answered 'unknown
platform' for a frontend whose packs do ship.
This commit is contained in:
Abdessamad Derraz committed 2026-08-11 00:54:27 +02:00
1 parent 958b988015
commit 185cf47bbc
5 files changed
+16676 -52

No files matched your search

+143
View File
@@ -556,6 +556,134 @@ class TestStandaloneCopiesExtraDirs(unittest.TestCase):
)
class TestBaseUrlScheme(unittest.TestCase):
"""The bootstraps insist on HTTPS; the installer must not be laxer."""
def test_https_is_accepted(self):
self.assertEqual(
install._checked_base_url("https://example.test/repo"),
"https://example.test/repo",
)
def test_plain_http_is_refused(self):
with self.assertRaises(SystemExit):
install._checked_base_url("http://example.test/repo")
def test_local_file_scheme_is_refused(self):
with self.assertRaises(SystemExit):
install._checked_base_url("file:///etc")
def test_loopback_http_is_allowed_for_end_to_end_runs(self):
for url in (
"http://127.0.0.1:8080/repo",
"http://localhost:8080/repo",
"http://[::1]:8080/repo",
):
self.assertEqual(install._checked_base_url(url), url)
class TestLocalCheckHashing(unittest.TestCase):
"""A file is read once, not once per declared digest."""
def test_both_digests_come_from_one_read(self):
tmp = Path(tempfile.mkdtemp())
payload = b"BIOS PAYLOAD"
(tmp / "boot.bin").write_bytes(payload)
entry = {
"dest": "boot.bin",
"size": len(payload),
"sha1": hashlib.sha1(payload).hexdigest(),
"sha256": hashlib.sha256(payload).hexdigest(),
}
calls = []
real_digest = install._digest_file
def recording(path, algorithms):
calls.append(tuple(sorted(algorithms)))
return real_digest(path, algorithms)
install._digest_file = recording
try:
to_download, up_to_date, mismatched = install.check_local([entry], tmp)
finally:
install._digest_file = real_digest
self.assertEqual((len(to_download), len(up_to_date), len(mismatched)), (0, 1, 0))
self.assertEqual(calls, [("sha1", "sha256")], "file must be read once")
def test_a_wrong_digest_still_lands_in_mismatched(self):
tmp = Path(tempfile.mkdtemp())
(tmp / "boot.bin").write_bytes(b"BIOS PAYLOAD")
entry = {
"dest": "boot.bin",
"size": 12,
"sha1": "0" * 40,
"sha256": hashlib.sha256(b"BIOS PAYLOAD").hexdigest(),
}
_, up_to_date, mismatched = install.check_local([entry], tmp)
self.assertEqual((len(up_to_date), len(mismatched)), (0, 1))
class TestStandaloneCopyTargetsAreUntrusted(unittest.TestCase):
"""The manifest names these directories, so it does not get to escape them."""
def _manifest(self, target: str) -> dict:
return {
"standalone_copies": [
{"file": "boot.bin", "targets": {"linux": [target]}}
]
}
def test_traversal_in_a_target_is_refused_by_validation(self):
manifest = {
"manifest_version": 2,
"platform": "retroarch",
"files": [],
"standalone_copies": [
{"file": "boot.bin", "targets": {"linux": ["~/emu/../../../etc"]}}
],
}
with self.assertRaises(ValueError):
install._validate_manifest(manifest, "retroarch")
def test_nul_in_a_target_is_refused_by_validation(self):
manifest = {
"manifest_version": 2,
"platform": "retroarch",
"files": [],
"standalone_copies": [
{"file": "boot.bin", "targets": {"linux": ["/tmp/a\x00b"]}}
],
}
with self.assertRaises(ValueError):
install._validate_manifest(manifest, "retroarch")
def test_plain_absolute_target_still_works(self):
manifest = {
"manifest_version": 2,
"platform": "retroarch",
"files": [],
"standalone_copies": [
{"file": "boot.bin", "targets": {"linux": ["~/.config/emu/bios"]}}
],
}
self.assertIsInstance(install._validate_manifest(manifest, "retroarch"), dict)
def test_symlinked_destination_is_not_written_through(self):
bios = Path(tempfile.mkdtemp())
(bios / "boot.bin").write_text("rom")
target = Path(tempfile.mkdtemp())
outside = Path(tempfile.mkdtemp()) / "secret"
outside.write_text("untouched")
(target / "boot.bin").symlink_to(outside)
copied, skipped = install.do_standalone_copies(
self._manifest(str(target)), bios, "linux"
)
self.assertEqual(copied, 0)
self.assertEqual(skipped, 1)
self.assertEqual(outside.read_text(), "untouched")
@unittest.skipUnless(shutil.which("pwsh"), "pwsh not available")
class TestLaunchboxDetectionPowershell(unittest.TestCase):
"""install.ps1 must resolve LaunchBox's portable RetroArch on its own."""
@@ -750,6 +878,21 @@ class TestAvailablePlatforms(unittest.TestCase):
manifests = {p.stem for p in (REPO_ROOT / "install").glob("*.json")}
self.assertEqual(set(install.AVAILABLE_PLATFORMS), manifests)
def test_every_registered_platform_is_installable(self):
"""Archived platforms still ship packs, so they still need a manifest."""
import sys as _sys
_sys.path.insert(0, str(REPO_ROOT / "scripts"))
from common import list_registered_platforms
registered = set(
list_registered_platforms(
str(REPO_ROOT / "platforms"), include_archived=True
)
)
missing = registered - set(install.AVAILABLE_PLATFORMS)
self.assertEqual(missing, set(), f"no install manifest for {sorted(missing)}")
def test_powershell_wrapper_pins_installer_hash(self):
content = (REPO_ROOT / "install.ps1").read_text()
match = re.search(r'\$defaultInstallSha256 = "([0-9a-f]{64})"', content)