mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-10 13:33:24 -05:00
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:
1 parent
bb915ed6aa
commit
9001ebb992
4 files changed
+586
-382
No files matched your search
+58
-330
@@ -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."
|
||||
Reference in new issue
Block a user