feat: harden the installer boundary

The manifest is untrusted input. It is now read under a size limit and
validated before use: destinations are contained below the BIOS root,
repo_path stays inside bios/, release_asset is a basename, hashes match
their shape, declared totals match their lists, and every entry has a
download source.

Downloads stream against the declared size, are checked by SHA-256 then
SHA-1, land in a per-process temporary file and are installed with
os.replace. Copies into standalone-emulator directories are opt-in with
--standalone-copies so a detection never writes outside the selected
tree.

Both bootstraps verify install.py against an embedded SHA-256 before
running it, and require the Python version install.py actually needs.

A target that publishes no core list is a target with no filter, not a
broken manifest: rejecting it disabled --target for the whole platform.
This commit is contained in:
Abdessamad Derraz committed 2026-08-10 13:36:52 +02:00
1 parent bb915ed6aa
commit 9001ebb992
4 files changed
+586 -382

No files matched your search

+58 -330
View File
@@ -1,335 +1,63 @@
# RetroBIOS installer for Windows (PowerShell 5+, no Python required)
# One-line bootstrap and local wrapper. The downloaded installer is accepted
# only when it matches the SHA-256 embedded in this wrapper.
[CmdletBinding()]
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$InstallerArguments
)
$ErrorActionPreference = "Stop"
$baseUrl = if ($env:RETROBIOS_BASE_URL) { $env:RETROBIOS_BASE_URL } else { "https://raw.githubusercontent.com/Abdess/retrobios/main" }
$releaseUrl = "https://github.com/Abdess/retrobios/releases/download/large-files"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "79630030c1b7445e2df02bcf0272c4530d24827b2589780b214489ab036d2e8c"
$maximumInstallerBytes = 2MB
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
$temporary = $null
$platform = $null
$biosPath = $null
try {
if (-not $installer -or -not (Test-Path -LiteralPath $installer -PathType Leaf)) {
$url = if ($env:RETROBIOS_INSTALL_URL) { $env:RETROBIOS_INSTALL_URL } else { $defaultInstallUrl }
$expected = if ($env:RETROBIOS_INSTALL_SHA256) { $env:RETROBIOS_INSTALL_SHA256 } else { $defaultInstallSha256 }
$uri = [Uri]$url
if ($uri.Scheme -ne "https") {
throw "RETROBIOS_INSTALL_URL must use HTTPS."
}
if ($expected -notmatch '^[0-9a-fA-F]{64}$') {
throw "Installer SHA-256 must contain exactly 64 hexadecimal characters."
}
$temporary = Join-Path ([IO.Path]::GetTempPath()) ("retrobios-install-{0}.py" -f [Guid]::NewGuid())
Invoke-WebRequest -Uri $uri -OutFile $temporary -UseBasicParsing
if ((Get-Item -LiteralPath $temporary).Length -gt $maximumInstallerBytes) {
throw "Downloaded installer exceeds the size limit."
}
$actual = (Get-FileHash -LiteralPath $temporary -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $expected.ToLowerInvariant()) {
throw "install.py SHA-256 mismatch."
}
$installer = $temporary
}
# Detect EmuDeck
$emudeckSettings = Join-Path $env:APPDATA "EmuDeck\settings.ps1"
if (Test-Path $emudeckSettings) {
$content = Get-Content $emudeckSettings -Raw
if ($content -match '\$emulationPath\s*=\s*"([^"]+)"') {
$platform = "emudeck"
$biosPath = Join-Path $Matches[1] "bios"
Write-Host "Found EmuDeck at $biosPath"
$python = Get-Command py -ErrorAction SilentlyContinue
if ($python) {
& $python.Source -3 -c "import sys; raise SystemExit(sys.version_info < (3, 8))"
if ($LASTEXITCODE -ne 0) { throw "Python 3.8 or newer is required." }
& $python.Source -3 $installer @InstallerArguments
if ($LASTEXITCODE -ne 0) { throw "RetroBIOS installer failed with exit code $LASTEXITCODE." }
return
}
$python = Get-Command python3 -ErrorAction SilentlyContinue
if (-not $python) {
$python = Get-Command python -ErrorAction SilentlyContinue
}
if (-not $python) {
throw "Python 3.8 or newer is required."
}
& $python.Source -c "import sys; raise SystemExit(sys.version_info < (3, 8))"
if ($LASTEXITCODE -ne 0) { throw "Python 3.8 or newer is required." }
& $python.Source $installer @InstallerArguments
if ($LASTEXITCODE -ne 0) { throw "RetroBIOS installer failed with exit code $LASTEXITCODE." }
}
finally {
if ($temporary -and (Test-Path -LiteralPath $temporary)) {
Remove-Item -LiteralPath $temporary -Force
}
}
# Expand the notations RetroArch writes into its config values:
# '~' is the home directory and ':' the application directory, both dropping
# two leading characters (libretro-common/file/file_path.c).
function Expand-RetroArchPath {
param([string]$Value, [string]$AppDir)
if ($Value.StartsWith('~')) { return Join-Path $env:USERPROFILE $Value.Substring(2) }
if ($Value.StartsWith(':')) { return Join-Path $AppDir $Value.Substring(2) }
return [Environment]::ExpandEnvironmentVariables($Value)
}
function Get-RetroArchSystemDir {
param([string]$CfgPath, [string]$AppDir)
if (-not (Test-Path $CfgPath)) { return $null }
foreach ($line in Get-Content $CfgPath) {
if ($line -match '^\s*system_directory\s*=\s*"?([^"]*)"?') {
$val = $Matches[1].Trim()
if (-not $val -or $val -eq "default") { return Join-Path $AppDir "system" }
return Expand-RetroArchPath -Value $val -AppDir $AppDir
}
}
return $null
}
# Detect RetroArch
if (-not $platform) {
$raCfg = Join-Path $env:APPDATA "RetroArch\retroarch.cfg"
if (Test-Path $raCfg) {
$platform = "retroarch"
$raRoot = Join-Path $env:APPDATA "RetroArch"
$found = Get-RetroArchSystemDir -CfgPath $raCfg -AppDir $raRoot
$biosPath = if ($found) { $found } else { Join-Path $raRoot "system" }
Write-Host "Found RetroArch at $biosPath"
}
}
# Locate LaunchBox. It installs wherever the user points its installer and
# writes no uninstall registry key, so the Start menu shortcut is the only
# record of that choice.
function Get-LaunchBoxRoot {
$candidates = @()
$lnk = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\LaunchBox\LaunchBox.lnk"
if (Test-Path $lnk) {
$bytes = [System.IO.File]::ReadAllBytes($lnk)
$text = [System.Text.Encoding]::ASCII.GetString($bytes)
if ($text -match '([A-Za-z]:\\[ -~]{0,260}?LaunchBox\.exe)') {
# The shortcut points at Core\LaunchBox.exe
$exeDir = Split-Path $Matches[1] -Parent
$candidates += (Split-Path $exeDir -Parent)
$candidates += $exeDir
}
}
$candidates += (Join-Path $env:USERPROFILE "LaunchBox")
foreach ($root in $candidates) {
if (Test-Path (Join-Path $root "Data\Emulators.xml")) { return $root }
}
return $null
}
# Map each emulator LaunchBox knows about to its installation directory.
# ApplicationPath is either absolute or relative to the LaunchBox root.
function Get-LaunchBoxEmulators {
param([string]$Root)
$map = @{}
if (-not $Root) { return $map }
$xmlPath = Join-Path $Root "Data\Emulators.xml"
if (-not (Test-Path $xmlPath)) { return $map }
$doc = $null
try { $doc = [xml](Get-Content $xmlPath -Raw) } catch { return $map }
foreach ($emu in $doc.LaunchBox.Emulator) {
$app = "$($emu.ApplicationPath)"
if (-not $app) { continue }
$exe = if ($app -match '^[A-Za-z]:' -or $app.StartsWith('\')) { $app } else { Join-Path $Root $app }
$dir = Split-Path $exe -Parent
$name = (Split-Path $exe -Leaf).ToLower()
if ((Test-Path $dir) -and -not $map.ContainsKey($name)) { $map[$name] = $dir }
}
return $map
}
# BIOS directories LaunchBox itself computes for the emulators it manages.
function Get-LaunchBoxBiosDirs {
$dirs = @{}
$root = Get-LaunchBoxRoot
if (-not $root) { return $dirs }
$emulators = Get-LaunchBoxEmulators -Root $root
$documents = Join-Path $env:USERPROFILE "Documents"
if ($emulators.ContainsKey("pcsx2.exe")) {
# portable.ini or portable.txt next to the executable moves the data
# root there, portable.txt naming a subfolder when it holds one
# (EmuFolders in pcsx2/Pcsx2Config.cpp). Otherwise it is Documents.
$emuDir = $emulators["pcsx2.exe"]
$portableTxt = Join-Path $emuDir "portable.txt"
$portable = (Test-Path (Join-Path $emuDir "portable.ini")) -or (Test-Path $portableTxt)
if ($portable) {
$subpath = if (Test-Path $portableTxt) { (Get-Content $portableTxt -Raw).Trim() } else { "" }
$dataRoot = if ($subpath) { Join-Path $emuDir $subpath } else { $emuDir }
} else {
$dataRoot = Join-Path $documents "PCSX2"
}
$ini = Join-Path $dataRoot "inis\PCSX2.ini"
$bios = Join-Path $dataRoot "bios"
if (Test-Path $ini) {
foreach ($line in Get-Content $ini) {
if ($line.StartsWith("Bios = ")) {
$val = $line.Substring(7).Trim()
$bios = if ($val -match '^[A-Za-z]:' -or $val.StartsWith('\')) { $val } else { Join-Path $dataRoot $val }
break
}
}
}
$dirs["pcsx2"] = $bios
}
if ($emulators.ContainsKey("xemu.exe")) {
# bootrom_path and flashrom_path name a file whose directory holds
# the images; both default to bios/ under the executable.
$emuDir = $emulators["xemu.exe"]
$toml = Join-Path $emuDir "xemu.toml"
if (-not (Test-Path $toml)) { $toml = Join-Path $env:APPDATA "xemu\xemu\xemu.toml" }
$bios = Join-Path $emuDir "bios"
if (Test-Path $toml) {
foreach ($line in Get-Content $toml) {
if ($line.StartsWith("bootrom_path") -or $line.StartsWith("flashrom_path")) {
$parts = $line.Split("'")
if ($parts.Count -gt 1 -and (Test-Path $parts[1])) {
$bios = Split-Path $parts[1] -Parent
break
}
}
}
}
$dirs["xemu"] = $bios
}
if ($emulators.ContainsKey("dolphin.exe")) {
# portable.txt beside the executable moves the user directory to User
# (SetUserDirectory in Source/Core/UICommon/UICommon.cpp)
$emuDir = $emulators["dolphin.exe"]
if (Test-Path (Join-Path $emuDir "portable.txt")) {
$dirs["dolphin"] = Join-Path $emuDir "User"
}
}
return $dirs
}
# Detect LaunchBox (portable RetroArch referenced in Data\Emulators.xml)
if (-not $platform) {
$lbRoot = Get-LaunchBoxRoot
$lbXml = if ($lbRoot) { Join-Path $lbRoot "Data\Emulators.xml" } else { $null }
if ($lbXml -and (Test-Path $lbXml)) {
$lb = $null
try { $lb = [xml](Get-Content $lbXml -Raw) } catch { Write-Host "Warning: could not parse $lbXml" }
if ($lb) {
foreach ($emu in $lb.LaunchBox.Emulator) {
$app = "$($emu.ApplicationPath)".Replace('\', '/')
if ($app -notmatch 'retroarch\.exe$') { continue }
$lbRoot = Split-Path (Split-Path $lbXml -Parent) -Parent
if ($app -match '^[A-Za-z]:' -or $app.StartsWith('/')) {
$exe = $app
} else {
$exe = Join-Path $lbRoot $app
}
$raDir = Split-Path $exe -Parent
if (Test-Path $raDir) {
$platform = "retroarch"
$found = Get-RetroArchSystemDir -CfgPath (Join-Path $raDir "retroarch.cfg") -AppDir $raDir
$biosPath = if ($found) { $found } else { Join-Path $raDir "system" }
Write-Host "Found LaunchBox with RetroArch at $biosPath"
break
}
}
}
}
}
# Fallback
if (-not $platform) {
$available = @("retroarch", "batocera", "recalbox", "retrobat", "emudeck", "lakka", "retrodeck", "rocknix", "romm", "bizhawk", "misterfpga")
$platform = (Read-Host "Platform ($($available -join ', '))").Trim().ToLower()
$biosPath = (Read-Host "BIOS directory path").Trim()
if (-not $platform -or -not $biosPath) {
Write-Host "Aborted." -ForegroundColor Red; exit 1
}
if ($available -notcontains $platform) {
Write-Host "Unknown platform '$platform'. Available: $($available -join ', ')" -ForegroundColor Red
exit 1
}
}
Write-Host "`nFetching file index for $platform..."
$manifest = Invoke-RestMethod "$baseUrl/install/$platform.json"
$files = $manifest.files
Write-Host " $($files.Count) files"
Write-Host "`nChecking existing files..."
$toDownload = @()
$upToDate = 0
foreach ($f in $files) {
$dest = Join-Path $biosPath $f.dest
if (Test-Path $dest) {
if ($f.sha1) {
$actual = (Get-FileHash $dest -Algorithm SHA1).Hash.ToLower()
if ($actual -eq $f.sha1) { $upToDate++; continue }
} else {
$upToDate++; continue
}
}
$toDownload += $f
}
Write-Host " $upToDate/$($files.Count) up to date, $($toDownload.Count) to download"
$downloaded = 0
$errors = 0
$total = $toDownload.Count
foreach ($f in $toDownload) {
$dest = Join-Path $biosPath $f.dest
$dir = Split-Path $dest -Parent
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
if ($f.release_asset) {
$url = "$releaseUrl/$($f.release_asset)"
} else {
$url = "$baseUrl/$($f.repo_path)"
}
$tmp = "$dest.tmp"
$ok = $false
foreach ($attempt in 1..3) {
try {
Invoke-WebRequest -Uri $url -OutFile $tmp -UseBasicParsing
} catch {
continue
}
if ($f.sha1) {
$actual = (Get-FileHash $tmp -Algorithm SHA1).Hash.ToLower()
if ($actual -ne $f.sha1) {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
continue
}
}
Move-Item $tmp $dest -Force
$ok = $true
break
}
if ($ok) {
$downloaded++
$i = $downloaded + $errors
Write-Host " [$i/$total] $($f.dest) ok"
} else {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
$errors++
$i = $downloaded + $errors
Write-Host " [$i/$total] $($f.dest) FAILED" -ForegroundColor Red
}
}
# Standalone emulator copies
if ($manifest.standalone_copies) {
Write-Host "`nStandalone emulators:"
$extraDirs = Get-LaunchBoxBiosDirs
foreach ($entry in $manifest.standalone_copies) {
if ($entry.note) {
$detectPaths = @()
if ($entry.detect -and $entry.detect.windows) {
$detectPaths = $entry.detect.windows
}
foreach ($dp in $detectPaths) {
$expanded = [Environment]::ExpandEnvironmentVariables($dp)
if (Test-Path $expanded) {
Write-Host " $($entry.note)"
break
}
}
continue
}
$sources = @()
if ($entry.pattern) {
$sources = Get-ChildItem -Path $biosPath -Filter $entry.pattern -File -ErrorAction SilentlyContinue
} elseif ($entry.file) {
$src = Join-Path $biosPath $entry.file
if (Test-Path $src) { $sources = @(Get-Item $src) }
}
if ($sources.Count -eq 0) { continue }
$targetDirs = @()
if ($entry.targets -and $entry.targets.windows) {
$targetDirs = $entry.targets.windows
}
if ($entry.emulator -and $extraDirs.ContainsKey($entry.emulator)) {
$extra = $extraDirs[$entry.emulator]
$subdir = if ($entry.file) { Split-Path $entry.file -Parent } else { "" }
if ($subdir) { $extra = Join-Path $extra $subdir }
$targetDirs += $extra
}
foreach ($td in $targetDirs) {
$expanded = [Environment]::ExpandEnvironmentVariables($td)
if (-not (Test-Path $expanded)) { continue }
foreach ($s in $sources) {
$dest = Join-Path $expanded $s.Name
try {
Copy-Item $s.FullName $dest -Force
Write-Host " $($s.Name) -> $expanded"
} catch {
Write-Host " $($s.Name) -> $expanded FAILED" -ForegroundColor Red
}
}
}
}
}
Write-Host "`nDone. $downloaded downloaded, $upToDate already up to date."