Add verified multipart Linux and Windows GitHub releases

This commit is contained in:
raptorreplica committed 2026-10-20 21:37:49 +11:00
1 parent 443d77852b
commit d1f5b7e5a2
8 files changed
+1152 -1

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
set -euo pipefail
[[ $(uname -s) == Linux && $(uname -m) == x86_64 ]] || { echo 'This package requires Linux x86_64.' >&2; exit 1; }
command -v python3 >/dev/null || { echo 'Install Python 3 to use this download helper. The game itself does not require Python.' >&2; exit 1; }
python3 - "${1:-$PWD}" <<'PYTHON'
# BEGIN INSTALLER PYTHON
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import stat
import sys
import tempfile
import urllib.request
import zipfile
TAG = 'v0.1.0-preview.20261020'
BASE = 'https://github.com/Quintine/RustRaptor/releases/download/' + TAG
def safe_name(name):
if not isinstance(name, str) or not name.startswith('RustRaptor/') or '\\' in name:
raise ValueError('Unsafe ZIP member path')
parts = name.split('/')
if any(not p or p in ('.', '..') or ':' in p or any(ord(c) < 32 for c in p) for p in parts):
raise ValueError('Unsafe ZIP member path')
return name
def extract(archive_path, destination, files):
expected = {safe_name('RustRaptor/' + row['name']): row for row in files}
if len(expected) != len(files):
raise ValueError('Duplicate planned ZIP members')
with zipfile.ZipFile(archive_path) as archive:
if len(archive.infolist()) != len(expected) or set(archive.namelist()) != set(expected):
raise ValueError('ZIP membership differs from frozen release manifest')
for member in archive.infolist():
safe_name(member.filename)
mode = member.external_attr >> 16
if member.is_dir() or stat.S_IFMT(mode) not in (0, stat.S_IFREG):
raise ValueError('ZIP member is not an ordinary file')
row = expected[member.filename]
if member.file_size != row['bytes'] or not re.fullmatch('[0-9a-f]{64}', row['sha256']):
raise ValueError('Invalid member size or SHA-256')
target = destination / member.filename
# The installation root must be fresh; refuse links and collisions.
parent = destination
for part in Path(member.filename).parts[:-1]:
parent /= part
if parent.is_symlink() or (parent.exists() and not parent.is_dir()):
raise ValueError('Unsafe installation parent')
parent.mkdir(exist_ok=True)
digest = hashlib.sha256()
with target.open('xb') as output, archive.open(member) as source:
while block := source.read(128 * 1024):
digest.update(block)
output.write(block)
if digest.hexdigest() != row['sha256']:
raise ValueError('Installed payload SHA-256 differs from frozen manifest')
target.chmod(0o755 if mode & 0o111 else 0o644)
def install(destination):
destination.mkdir(parents=True, exist_ok=True)
destination = destination.resolve(strict=True)
if (destination / 'RustRaptor').exists() or (destination / 'RustRaptor').is_symlink():
raise ValueError('Use a directory without an existing RustRaptor folder')
with tempfile.TemporaryDirectory(prefix='RustRaptor-download-') as temp:
cache = Path(temp)
urllib.request.urlretrieve(BASE + '/release-manifest.json', cache / 'manifest.json')
manifest = json.loads((cache / 'manifest.json').read_bytes())
if manifest['format_version'] != 1 or manifest['tag'] != TAG:
raise ValueError('Wrong release manifest version or tag')
assets = [a for a in manifest['assets'] if a['kind'] in ('shared-data', 'linux')]
names = [a['name'] for a in assets]
data = [a for a in assets if a['kind'] == 'shared-data']
if names.count('RustRaptor-linux-x86_64.zip') != 1 or not data or len(set(names)) != len(names):
raise ValueError('Incomplete release asset manifest')
required = {f'RustRaptor-data-{i:02d}-of-{len(data):02d}.zip' for i in range(1, len(data) + 1)}
if {a['name'] for a in data} != required:
raise ValueError('Incomplete data-volume sequence')
all_files = [row['name'].casefold() for a in assets for row in a['files']]
if len(set(all_files)) != len(all_files):
raise ValueError('Duplicate installation paths across archives')
needed = sum(row['bytes'] for a in assets for row in a['files'])
if shutil.disk_usage(destination).free < needed + 128 * 1024**2:
raise ValueError('Insufficient space for the complete installation')
for asset in assets:
name = asset['name']
if name != 'RustRaptor-linux-x86_64.zip' and name not in required:
raise ValueError('Unsafe release asset name')
if not re.fullmatch('[0-9a-f]{64}', asset['sha256']) or not 0 < asset['bytes'] < 2 * 1024**3:
raise ValueError('Invalid release asset SHA-256 or size')
if shutil.disk_usage(cache).free < asset['bytes'] + 128 * 1024**2:
raise ValueError('Insufficient temporary space for one download')
print('Downloading ' + name, flush=True)
archive_path = cache / name
urllib.request.urlretrieve(BASE + '/' + name, archive_path)
with archive_path.open('rb') as source:
digest = hashlib.sha256()
while block := source.read(128 * 1024):
digest.update(block)
if archive_path.stat().st_size != asset['bytes'] or digest.hexdigest() != asset['sha256']:
raise ValueError('Downloaded archive size or SHA-256 mismatch: ' + name)
extract(archive_path, destination, asset['files'])
archive_path.unlink()
print('Installed. Start: ' + str(destination / 'RustRaptor/run.sh'))
if __name__ == '__main__':
install(Path(sys.argv[1]))
# END INSTALLER PYTHON
PYTHON
+147
View File
@@ -0,0 +1,147 @@
param([string]$Destination = (Get-Location).Path)
$ErrorActionPreference = 'Stop'
$tag = 'v0.1.0-preview.20261020'
$base = "https://github.com/Quintine/RustRaptor/releases/download/$tag"
if (-not [Environment]::Is64BitOperatingSystem) { throw 'RustRaptor requires 64-bit Windows.' }
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# Use wide Win32 APIs and extended paths: Windows PowerShell 5.1 Expand-Archive
# cannot reliably extract the complete accepted proof/lineage long filenames.
if (-not ('RaptorLongPath' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.ComponentModel;
using Microsoft.Win32.SafeHandles;
public static class RaptorLongPath {
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, ExactSpelling=true, SetLastError=true)]
static extern uint GetFileAttributesW(string name);
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, ExactSpelling=true, SetLastError=true)]
static extern bool CreateDirectoryW(string name, IntPtr security);
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, ExactSpelling=true, SetLastError=true)]
static extern SafeFileHandle CreateFileW(string name, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr template);
static string Extended(string path) {
if (path.StartsWith(@"\\?\")) return path;
if (path.StartsWith(@"\\")) return @"\\?\UNC\" + path.Substring(2);
return @"\\?\" + path;
}
public static bool Exists(string path) { return GetFileAttributesW(Extended(path)) != 0xffffffff; }
public static void Directory(string path) {
string name = Extended(path);
uint attributes = GetFileAttributesW(name);
if (attributes == 0xffffffff) {
if (!CreateDirectoryW(name, IntPtr.Zero)) throw new Win32Exception(Marshal.GetLastWin32Error());
attributes = GetFileAttributesW(name);
}
if ((attributes & 0x400) != 0 || (attributes & 0x10) == 0) throw new IOException("Unsafe installation directory");
}
public static void Extract(Stream input, string path, long expectedSize, string expectedHash) {
using (SafeFileHandle handle = CreateFileW(Extended(path), 0x40000000, 0, IntPtr.Zero, 1, 0x80, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
using (FileStream output = new FileStream(handle, FileAccess.Write))
using (var sha = System.Security.Cryptography.SHA256.Create()) {
byte[] buffer = new byte[131072]; long count = 0; int read;
while ((read = input.Read(buffer, 0, buffer.Length)) != 0) {
count += read; if (count > expectedSize) throw new IOException("Payload exceeds planned size");
sha.TransformBlock(buffer, 0, read, buffer, 0); output.Write(buffer, 0, read);
}
sha.TransformFinalBlock(new byte[0], 0, 0);
string hash = BitConverter.ToString(sha.Hash).Replace("-", "").ToLowerInvariant();
if (count != expectedSize || hash != expectedHash) throw new IOException("Installed payload size or SHA-256 mismatch");
}
}
}
}
'@
}
function Assert-Member([string]$Name) {
if (-not $Name.StartsWith('RustRaptor/') -or $Name.Contains('\')) { throw 'Unsafe ZIP member path.' }
foreach ($part in $Name.Split('/')) {
if ($part -eq '' -or $part -eq '.' -or $part -eq '..' -or $part -match '[<>:"|?*\x00-\x1f]' -or
$part.EndsWith('.') -or $part.EndsWith(' ') -or $part -match '^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)') {
throw 'Unsafe or nonportable ZIP member path.'
}
}
}
New-Item -ItemType Directory -Force -Path $Destination | Out-Null
$Destination = (Resolve-Path -LiteralPath $Destination).Path
[RaptorLongPath]::Directory($Destination)
$installation = $Destination.TrimEnd('\') + '\RustRaptor'
if ([RaptorLongPath]::Exists($installation)) { throw 'Use a directory without an existing RustRaptor folder.' }
$cache = Join-Path ([IO.Path]::GetTempPath()) ('RustRaptor-download-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $cache | Out-Null
try {
$manifestPath = Join-Path $cache 'release-manifest.json'
Invoke-WebRequest -UseBasicParsing -Uri "$base/release-manifest.json" -OutFile $manifestPath
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
if ($manifest.format_version -ne 1 -or $manifest.tag -cne $tag) { throw 'Wrong release manifest version or tag.' }
$assets = @($manifest.assets | Where-Object { $_.kind -in @('shared-data', 'windows') })
$data = @($assets | Where-Object { $_.kind -eq 'shared-data' })
$names = @{}
$paths = @{}
$platformCount = 0
$required = @{}
for ($i = 1; $i -le $data.Count; $i++) { $required.Add(('RustRaptor-data-{0:D2}-of-{1:D2}.zip' -f $i, $data.Count), $true) }
foreach ($asset in $assets) {
if ($names.ContainsKey($asset.name)) { throw 'Duplicate release asset name.' }
$names.Add($asset.name, $true)
if ($asset.kind -eq 'windows') {
if ($asset.name -cne 'RustRaptor-windows-x86_64.zip') { throw 'Wrong platform archive name.' }
$platformCount++
} elseif (-not $required.ContainsKey($asset.name)) { throw 'Incomplete data-volume sequence.' }
if ($asset.sha256 -cnotmatch '^[0-9a-f]{64}$' -or $asset.bytes -le 0 -or $asset.bytes -ge 2147483648) { throw 'Invalid asset size or SHA-256.' }
foreach ($row in $asset.files) {
Assert-Member ('RustRaptor/' + $row.name)
if ($paths.ContainsKey($row.name)) { throw 'Duplicate installation path across archives.' }
$paths.Add($row.name, $true)
if ($row.bytes -lt 0 -or $row.sha256 -cnotmatch '^[0-9a-f]{64}$') { throw 'Invalid payload size or SHA-256.' }
}
}
if ($platformCount -ne 1 -or $data.Count -eq 0 -or $assets.Count -ne ($data.Count + 1)) { throw 'Incomplete release asset manifest.' }
Add-Type -AssemblyName System.IO.Compression.FileSystem
foreach ($asset in $assets) {
$name = $asset.name
Write-Host "Downloading $name"
$archivePath = Join-Path $cache $name
Invoke-WebRequest -UseBasicParsing -Uri "$base/$name" -OutFile $archivePath
if ((Get-Item -LiteralPath $archivePath).Length -ne $asset.bytes -or
(Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $asset.sha256) {
throw "Downloaded archive size or SHA-256 mismatch: $name"
}
$expected = @{}
foreach ($row in $asset.files) { $expected.Add(('RustRaptor/' + $row.name), $row) }
$archive = [IO.Compression.ZipFile]::OpenRead($archivePath)
try {
if ($archive.Entries.Count -ne $expected.Count) { throw 'ZIP membership differs from frozen manifest.' }
$seen = @{}
foreach ($entry in $archive.Entries) {
Assert-Member $entry.FullName
if (-not $expected.ContainsKey($entry.FullName) -or $seen.ContainsKey($entry.FullName)) { throw 'Unexpected or duplicate ZIP member.' }
$seen.Add($entry.FullName, $true)
$row = $expected[$entry.FullName]
$mode = ($entry.ExternalAttributes -shr 16) -band 0xF000
if ($mode -ne 0 -and $mode -ne 0x8000) { throw 'ZIP member is not an ordinary file.' }
if ($entry.Length -ne $row.bytes) { throw 'ZIP member size differs from frozen manifest.' }
}
foreach ($entry in $archive.Entries) {
$parts = $entry.FullName.Split('/')
$parent = $Destination.TrimEnd('\')
for ($i = 0; $i -lt ($parts.Length - 1); $i++) {
$parent += '\' + $parts[$i]
[RaptorLongPath]::Directory($parent)
}
$target = $parent + '\' + $parts[$parts.Length - 1]
$source = $entry.Open()
try { [RaptorLongPath]::Extract($source, $target, $expected[$entry.FullName].bytes, $expected[$entry.FullName].sha256) }
finally { $source.Dispose() }
}
} finally { $archive.Dispose() }
Remove-Item -LiteralPath $archivePath
}
Write-Host ('Installed. Start ' + $installation + '\run.cmd')
} catch {
Write-Warning 'Installation did not complete. Any partial RustRaptor folder has been preserved; move or remove it before retrying.'
throw
} finally {
Remove-Item -LiteralPath $cache -Recurse -Force
}
+56
View File
@@ -0,0 +1,56 @@
# Native Windows PowerShell 5.1 synthetic ZIP/install smoke test. No game build,
# network download, admin rights or long-path registry configuration is needed.
param([string]$Installer = (Join-Path $PSScriptRoot 'Install-Windows.ps1'))
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.IO.Compression.FileSystem
$temp = Join-Path ([IO.Path]::GetTempPath()) ('Raptor-installer-test-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $temp | Out-Null
$global:RaptorInstallerFixtureDownloads = Join-Path $temp 'downloads'
New-Item -ItemType Directory -Path $global:RaptorInstallerFixtureDownloads | Out-Null
function global:Invoke-WebRequest {
param([switch]$UseBasicParsing, [string]$Uri, [string]$OutFile)
$leaf = [IO.Path]::GetFileName(([Uri]$Uri).AbsolutePath)
Copy-Item -LiteralPath (Join-Path $global:RaptorInstallerFixtureDownloads $leaf) -Destination $OutFile
}
function New-FixtureArchive([string]$Name, [string]$Kind, [string]$Member) {
$path = Join-Path $global:RaptorInstallerFixtureDownloads $Name
$body = [Text.Encoding]::UTF8.GetBytes('synthetic payload ' + $Member)
$zip = [IO.Compression.ZipFile]::Open($path, [IO.Compression.ZipArchiveMode]::Create)
try {
$entry = $zip.CreateEntry('RustRaptor/' + $Member)
$entry.ExternalAttributes = (0x81A4 -shl 16)
$stream = $entry.Open()
try { $stream.Write($body, 0, $body.Length) } finally { $stream.Dispose() }
} finally { $zip.Dispose() }
$sha = [Security.Cryptography.SHA256]::Create()
try { $payloadHash = [BitConverter]::ToString($sha.ComputeHash($body)).Replace('-', '').ToLowerInvariant() }
finally { $sha.Dispose() }
return @{ name=$Name; kind=$Kind; bytes=(Get-Item -LiteralPath $path).Length;
sha256=(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant();
files=@(@{name=$Member; bytes=$body.Length; sha256=$payloadHash}) }
}
try {
$longMember = 'data/' + (('long-proof-component/' * 20)) + 'accepted.json'
$data = New-FixtureArchive 'RustRaptor-data-01-of-01.zip' 'shared-data' $longMember
$core = New-FixtureArchive 'RustRaptor-windows-x86_64.zip' 'windows' 'run.cmd'
$manifest = @{format_version=1; tag='v0.1.0-preview.20261020'; assets=@($data, $core)}
$manifestPath = Join-Path $global:RaptorInstallerFixtureDownloads 'release-manifest.json'
$manifest | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $manifestPath -Encoding UTF8
$destination = Join-Path $temp 'portable folder'
& $Installer -Destination $destination
$actual = $destination + '\RustRaptor\' + $longMember.Replace('/', '\')
if (-not [RaptorLongPath]::Exists($actual)) { throw 'Long-path accepted proof was not installed.' }
if (-not (Test-Path -LiteralPath (Join-Path $destination 'RustRaptor\run.cmd'))) { throw 'Platform core was not installed.' }
# A second fresh install with a corrupted payload expectation must fail.
$manifest.assets[0].files[0].sha256 = '0' * 64
$manifest | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $manifestPath -Encoding UTF8
$failed = $false
try { & $Installer -Destination (Join-Path $temp 'bad hash') } catch { $failed = $true }
if (-not $failed) { throw 'Incorrect payload SHA-256 was accepted.' }
Write-Host 'PASS: Windows PowerShell ZIP manifest, complete payload, >260-character extraction, and bad-hash rejection.'
} finally {
Remove-Item Function:\Invoke-WebRequest -ErrorAction SilentlyContinue
Remove-Variable RaptorInstallerFixtureDownloads -Scope Global -ErrorAction SilentlyContinue
# Windows built-in tar supports long-path cleanup without changing OS policy.
if (Get-Command cmd.exe -ErrorAction SilentlyContinue) { & cmd.exe /d /c ('rmdir /s /q "' + $temp + '"') | Out-Null }
}