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

+54
View File
@@ -0,0 +1,54 @@
name: Release packaging
on:
workflow_dispatch:
push:
paths:
- '.github/workflows/release-packaging.yml'
- 'release/**'
- 'tools/export_release.py'
- 'tools/test_export_release.py'
- 'tools/assemble_runtime.py'
pull_request:
paths:
- '.github/workflows/release-packaging.yml'
- 'release/**'
- 'tools/export_release.py'
- 'tools/test_export_release.py'
- 'tools/assemble_runtime.py'
permissions:
contents: read
jobs:
linux-exporter:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
tools
release
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Verify exporter and Linux installer using synthetic ZIPs
run: |
bash -n release/Install-Linux.sh
python -B -m unittest discover -s tools -p test_export_release.py -v
windows-installer:
runs-on: windows-latest
timeout-minutes: 10
steps:
- name: Allow long names during checkout
shell: powershell
run: git config --global core.longpaths true
- uses: actions/checkout@v4
with:
sparse-checkout: |
release
- name: Verify native PowerShell 5.1 installer and long-path extraction
shell: powershell
run: .\release\Test-Install-Windows.ps1
+3 -1
View File
@@ -18,7 +18,9 @@ the launcher falls back to Cargo when no packaged executable is present.
`--data DIR` selects another catalog. Some optional comparison tests use retained source.
On Windows, double-click `run.cmd`; it starts the included
`bin/raptor-rust.exe` with its adjacent SDL2 DLL and the same data and mods.
See [Windows builds and packaging](docs/windows-build.md).
See [Windows builds and packaging](docs/windows-build.md). Downloadable Linux and Windows packages are
published in [GitHub Releases](https://github.com/Quintine/RustRaptor/releases);
see the [download and installation guide](docs/github-releases.md).
Refresh the executable and copied original/approved-HD data with
`python3 tools/assemble_runtime.py`. Add `--zip dist/RustRaptor-linux-x86_64.zip`
+86
View File
@@ -0,0 +1,86 @@
# Downloadable releases
The GitHub release provides playable Linux x86_64 and Windows x86_64 packages.
Cargo, Python, the old engine and a Git checkout are unnecessary for play.
The approved HD artwork makes the shared data larger than GitHub's
[2 GiB limit per release asset](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases).
Each platform ZIP therefore contains the executable, platform libraries, all
45 mods with SD/HD artwork and audio, and the source/docs. Shared data ZIPs
contain the complete unchanged original catalog, approved HD pack, briefings
and the runtime manifest's other data files. Extracting them together produces
one self-contained `RustRaptor` folder. A single ZIP is only part of the download.
## Install
Download `Install-Windows.ps1` or `Install-Linux.sh` from the release. Review it,
then run it in a directory without an existing `RustRaptor` folder. It downloads
your platform ZIP and every shared data ZIP, checks each archive and every extracted payload against the versioned
`release-manifest.json` (and publishes `SHA256SUMS` for manual verification),
and extracts them into the same destination. Downloads are processed one at a
time. Linux needs Bash and Python 3.8 or later for this optional installer; Windows uses
built-in Windows PowerShell 5.1 and .NET. The Windows helper uses explicit extended
paths to retain long accepted proof filenames without an admin policy change.
A failed install preserves its partial folder for inspection; move or remove that
folder before retrying. All archives reject unexpected paths, links and file collisions.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\Install-Windows.ps1
```
```sh
bash ./Install-Linux.sh
```
For manual installation:
1. Download **one** platform ZIP: `RustRaptor-windows-x86_64.zip` or
`RustRaptor-linux-x86_64.zip`.
2. Download **every** `RustRaptor-data-NN-of-TT.zip` and `SHA256SUMS` from
the **same release**. Do not mix snapshots.
3. Verify the downloaded hashes. On Windows use `Get-FileHash -Algorithm SHA256`;
on Linux use `sha256sum`. Compare them with `SHA256SUMS`.
4. Extract every downloaded ZIP into the **same parent directory**. Every ZIP
starts with `RustRaptor/`; do not make a different subfolder for each ZIP.
5. Start `RustRaptor/run.cmd` on Windows or `RustRaptor/run.sh` on Linux.
Keep enough storage for the installed folder plus one data ZIP during install.
The release notes state the exact installed/download sizes. Your pilots and
settings stay in the separate `RaptorReplica` user profile directories.
## Maintainer export
Export only a clean, fully committed final tested commit with current Linux and Windows manifests.
The exporter does not build, refresh the runtime, change manifests or upload.
```sh
python3 tools/export_release.py plan --package . \
--expected-commit FULL_TESTED_COMMIT --output /tmp/release-plan.json
python3 tools/export_release.py archive --plan /tmp/release-plan.json \
--asset RustRaptor-data-01-of-07.zip \
--output /tmp/RustRaptor-data-01-of-07.zip \
--receipt /path/to/receipts/RustRaptor-data-01-of-07.zip.json
```
Use the actual volume count in the plan. Each asset is under 1.5 GB before ZIP
overhead, is checked against every planned source SHA-256 while writing, and
passes whole-archive CRC validation before a receipt is produced. The exporter
retains Linux execute permissions. The exporter inventories selected source files without following symlinks and
rejects non-regular members. It preserves every physical runtime-data file,
including directories named `derived`; proof/lineage files already present in
the runtime manifest remain included.
Create a draft release at the exact tested tag. Write, verify and upload one
archive at a time, record its remote size/digest, then remove only that disposable
export copy before producing the next. Keep receipts. After all assets exist:
```sh
python3 tools/export_release.py checksums --plan /tmp/release-plan.json \
--receipts /path/to/receipts --output /tmp/SHA256SUMS \
--manifest-output /tmp/release-manifest.json --tag v0.1.0-preview.20261020
```
Upload the versioned release manifest, checksum list and installers, check complete remote membership, and
publish the draft only after all checks pass. Native Windows CI and local
representative rendering/audio evidence retain their actual commit and scope;
archive integrity checks do not imply a fresh full playthrough.
+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 }
}
+368
View File
@@ -0,0 +1,368 @@
#!/usr/bin/env python3
"""Export a frozen, unchanged playable package as independently extractable ZIPs.
No builds, data staging, runtime-manifest rewriting, Git changes or uploads.
Each platform core plus ALL shared data ZIPs extracts into one RustRaptor folder.
"""
import argparse
from contextlib import contextmanager
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import time
import zipfile
from assemble_runtime import checked_file, digest
MAX_ASSET_BYTES = 2 * 1024**3 # GitHub requires each release asset UNDER 2 GiB.
DEFAULT_VOLUME_BYTES = 1_500_000_000
ROOT_DISK_FLOOR = 1_800_000_000
SKIPPED = {"target", "dist", ".reference", ".local", "derived", "__pycache__", ".git"}
def read_json(path):
return json.loads(Path(path).read_bytes())
def write_new_json(path, value):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("x", encoding="utf-8") as stream:
json.dump(value, stream, indent=2)
stream.write("\n")
def git_commit(package):
return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=package, text=True).strip()
def require_clean_workspace(package):
if subprocess.check_output(["git", "status", "--porcelain", "--untracked-files=all"], cwd=package):
raise ValueError("release exports require a clean, fully committed workspace")
def snapshot_identity(package):
return {"commit": git_commit(package),
"runtime_manifest_sha256": digest(package / "runtime-manifest.json"),
"windows_manifest_sha256": digest(package / "runtime-windows-manifest.json")}
def check_snapshot(package, plan):
require_clean_workspace(package)
if snapshot_identity(package) != plan["snapshot"]:
raise ValueError("source commit or runtime manifests changed after release planning")
def entry(package, name, expected=None):
path = checked_file(package, name)
safe_member("RustRaptor/" + name)
return {"name": name, "bytes": path.stat().st_size,
"sha256": expected if expected is not None else digest(path)}
def safe_member(name):
"""All exports are portable ordinary files below the one installation root."""
if not isinstance(name, str) or not name.startswith("RustRaptor/") or "\\" in name:
raise ValueError("unsafe release member path")
parts = name.split("/")
reserved = {"CON", "PRN", "AUX", "NUL"} | {f"{prefix}{i}" for prefix in ("COM", "LPT") for i in range(1, 10)}
if any(not part or part in (".", "..") or part.endswith((".", " "))
or any(ord(c) < 32 or c in '<>:"|?*' for c in part)
or part.split(".")[0].upper() in reserved for part in parts):
raise ValueError("unsafe or nonportable release member path")
return name
def selected_runtime_files(package):
"""Explicit finite no-follow inventory; skipped private namespaces stay private."""
files = []
visited = 0
def excluded(relative):
if relative.parts[0] == "data":
return False # The complete physical runtime tree is mandatory.
return (bool(SKIPPED.intersection(relative.parts))
or any(part.startswith(".runtime-data-") for part in relative.parts)
or (len(relative.parts) >= 4 and relative.parts[0] == "mods"
and relative.parts[2:4] == ("art", "source")))
def walk(directory, depth):
nonlocal visited
if depth > 128:
raise ValueError("release source tree exceeds the 128-level inventory bound")
with os.scandir(directory) as stream:
children = sorted(stream, key=lambda child: child.name)
for child in children:
path = Path(child.path)
relative = path.relative_to(package)
if excluded(relative):
continue
visited += 1
if visited > 1_000_000:
raise ValueError("release source tree exceeds the one-million-entry inventory bound")
metadata = child.stat(follow_symlinks=False)
if stat.S_ISLNK(metadata.st_mode):
raise ValueError(f"symlink in selected release tree: {relative}")
if stat.S_ISDIR(metadata.st_mode):
walk(path, depth + 1)
elif stat.S_ISREG(metadata.st_mode):
files.append(path)
else:
raise ValueError(f"non-regular member in selected release tree: {relative}")
walk(package, 0)
return sorted(files)
def partition(rows, budget):
if type(budget) is not int or not 0 < budget <= DEFAULT_VOLUME_BYTES:
raise ValueError("ZIP volume budget must be positive and at most 1,500,000,000 bytes")
groups, current, size = [], [], 0
for row in rows:
if row["bytes"] > budget:
raise ValueError(f"one runtime file exceeds ZIP volume budget: {row['name']}")
if current and size + row["bytes"] > budget:
groups.append(current)
current, size = [], 0
current.append(row)
size += row["bytes"]
if current:
groups.append(current)
return groups
def build_plan(package, expected_commit, budget=DEFAULT_VOLUME_BYTES):
package = Path(package).resolve(strict=True)
if not re.fullmatch(r"[0-9a-f]{40}", expected_commit):
raise ValueError("an exact final tested Git commit is required")
require_clean_workspace(package)
snapshot = snapshot_identity(package)
if snapshot["commit"] != expected_commit:
raise ValueError("package HEAD is not the requested tested checkpoint")
selected = selected_runtime_files(package)
manifest = read_json(package / "runtime-manifest.json")
windows = read_json(package / "runtime-windows-manifest.json")
hd = read_json(package / "data/hd/hd.json")
hd_count = sum(len(sequence["frames"]) for sequence in hd["sequences"])
if hd_count != manifest["approved_hd_frames"] or windows["approved_hd_frames"] != hd_count:
raise ValueError("shared data and platform HD counts differ")
if digest(package / "bin/raptor-rust") != manifest["binary_sha256"]:
raise ValueError("included Linux executable differs from its runtime manifest")
for name, expected in windows["files"].items():
if digest(checked_file(package, name)) != expected:
raise ValueError(f"included Windows payload differs: {name}")
rows = [entry(package, name, expected) for name, expected in sorted(manifest["files"].items())]
if any(not row["name"].startswith("data/") for row in rows):
raise ValueError("shared runtime manifest must contain data/ paths only")
if len(rows) != manifest["data_files"] or sum(row["bytes"] for row in rows) != manifest["data_bytes"]:
raise ValueError("shared runtime file count/bytes differ from its manifest")
for sequence in hd["sequences"]:
for frame in sequence["frames"]:
checked_file(package / "data/hd", frame["file"])
if "data/hd/" + frame["file"] not in manifest["files"]:
raise ValueError("active HD artwork is absent from the shared runtime manifest")
physical_data = {path.relative_to(package).as_posix() for path in selected
if path.relative_to(package).parts[0] == "data"}
if physical_data != set(manifest["files"]):
raise ValueError("runtime manifest does not cover the complete physical data tree")
groups = partition(rows, budget)
assets = [{"name": f"RustRaptor-data-{index:02d}-of-{len(groups):02d}.zip",
"kind": "shared-data", "files": group,
"uncompressed_bytes": sum(row["bytes"] for row in group)}
for index, group in enumerate(groups, 1)]
core = [entry(package, path.relative_to(package).as_posix()) for path in selected
if path.relative_to(package).parts[0] != "data"]
for platform in ("linux", "windows"):
if platform == "linux":
excluded = {"bin/raptor-rust.exe", "bin/SDL2.dll", "bin/SDL2-WINDOWS-LICENSE.txt",
"run.cmd", "runtime-windows-manifest.json"}
files = [row for row in core if row["name"] not in excluded]
else:
files = [row for row in core if row["name"] not in {"bin/raptor-rust", "run.sh"}
and not row["name"].startswith("lib/")]
if sum(row["bytes"] for row in files) > DEFAULT_VOLUME_BYTES:
raise ValueError("platform core exceeds the safe single-asset budget")
assets.append({"name": f"RustRaptor-{platform}-x86_64.zip", "kind": platform,
"files": files, "uncompressed_bytes": sum(row["bytes"] for row in files)})
plan = {"format_version": 1, "package": str(package), "snapshot": snapshot,
"catalog_entries": manifest["catalog_entries"], "approved_hd_frames": hd_count,
"bundled_mods": len(list((package / "mods").glob("*/mod.toml"))),
"data_files": len(rows), "data_bytes": manifest["data_bytes"],
"volume_uncompressed_budget": budget, "assets": assets,
"install": "Extract your platform ZIP and every shared-data ZIP into the same directory; run RustRaptor/run.sh or RustRaptor/run.cmd.",
"payload_scope": "Complete unchanged runtime-manifest data plus explicitly inventoried mods, source, docs and platform libraries. No proof/lineage files from the manifest are silently removed."}
for platform in ("linux", "windows"):
installation = [row["name"].casefold() for asset in assets
if asset["kind"] in ("shared-data", platform) for row in asset["files"]]
if len(installation) != len(set(installation)):
raise ValueError("case-insensitive duplicate installation path across assets")
for asset in assets:
seen = set()
for row in asset["files"]:
key = row["name"].casefold()
if key in seen:
raise ValueError("case-insensitive duplicate release path")
seen.add(key)
check_snapshot(package, plan)
return plan
def source_identity(metadata):
return metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns
@contextmanager
def opened_source(package, name, expected_size):
relative = Path(name)
if relative.is_absolute() or not relative.parts or any(part in (".", "..") for part in relative.parts):
raise ValueError("unsafe release member path")
flags = os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK
directory = os.open("/", flags | os.O_DIRECTORY)
source = None
try:
for part in package.parts[1:] + relative.parts[:-1]:
following = os.open(part, flags | os.O_DIRECTORY, dir_fd=directory)
os.close(directory)
directory = following
source = os.open(relative.name, flags, dir_fd=directory)
before = os.fstat(source)
if not stat.S_ISREG(before.st_mode) or before.st_size != expected_size:
raise ValueError("release source is not the planned regular file")
with os.fdopen(source, "rb", closefd=False) as stream:
yield stream, before
if source_identity(before) != source_identity(os.fstat(source)):
raise ValueError("release source changed while being read")
finally:
if source is not None:
os.close(source)
os.close(directory)
def write_asset(plan_path, asset_name, output):
plan_path, output = Path(plan_path), Path(output)
plan = read_json(plan_path)
package = Path(plan["package"]).resolve(strict=True)
check_snapshot(package, plan)
matches = [asset for asset in plan["assets"] if asset["name"] == asset_name]
if len(matches) != 1 or Path(asset_name).name != asset_name:
raise ValueError("choose one exact planned release asset")
asset = matches[0]
if output.name != asset_name:
raise ValueError("output filename must equal the planned asset name")
if output.exists():
raise ValueError("archive output already exists; never overwrite exports")
output.parent.mkdir(parents=True, exist_ok=True)
if shutil.disk_usage(package).free < ROOT_DISK_FLOOR:
raise ValueError("workspace free space is below the 1.8GB floor")
# Deflate worst-case and ZIP overhead fit this generous reserve. /tmp may
# be tmpfs: only one volume is held and no complete runtime tree is copied.
if shutil.disk_usage(output.parent).free < asset["uncompressed_bytes"] + 128 * 1024**2:
raise ValueError("insufficient output space for one archive and reserve")
if (output.parent.stat().st_dev == package.stat().st_dev
and shutil.disk_usage(package).free < ROOT_DISK_FLOOR + asset["uncompressed_bytes"] + 128 * 1024**2):
raise ValueError("same-filesystem output would cross the workspace 1.8GB floor")
measured = {}
with zipfile.ZipFile(output, "x", zipfile.ZIP_DEFLATED, compresslevel=6, allowZip64=True) as archive:
for row in asset["files"]:
h = hashlib.sha256()
with opened_source(package, row["name"], row["bytes"]) as (original, before):
info = zipfile.ZipInfo("RustRaptor/" + row["name"], time.localtime(before.st_mtime)[:6])
info.compress_type = zipfile.ZIP_DEFLATED
info.create_system = 3
info.external_attr = (before.st_mode & 0xFFFF) << 16
with archive.open(info, "w", force_zip64=True) as member:
remaining = row["bytes"]
while remaining:
block = original.read(min(128 * 1024, remaining))
if not block:
raise ValueError("release source ended before its planned size")
remaining -= len(block)
h.update(block)
member.write(block)
if original.read(1):
raise ValueError("release source grew beyond its planned size")
if h.hexdigest() != row["sha256"]:
raise ValueError(f"release source changed or differs from plan: {row['name']}")
measured[row["name"]] = h.hexdigest()
if output.stat().st_size >= MAX_ASSET_BYTES:
raise ValueError("release asset reaches GitHub's 2GiB limit")
with zipfile.ZipFile(output) as archive:
if archive.testzip() is not None:
raise ValueError("release ZIP failed CRC validation")
if archive.namelist() != ["RustRaptor/" + row["name"] for row in asset["files"]]:
raise ValueError("release ZIP membership differs from plan")
check_snapshot(package, plan)
return {"status": "ZIP_CRC_AND_COMPLETE_PAYLOAD_SHA256_PASS", "asset": asset_name,
"kind": asset["kind"], "bytes": output.stat().st_size, "sha256": digest(output),
"plan_sha256": digest(plan_path), "checkpoint": plan["snapshot"],
"payload_files": len(measured), "payload_sha256": measured,
"root_disk_floor_after_bytes": shutil.disk_usage(package).free,
"builds_or_source_manifest_mutations": 0}
def checksums(plan_path, receipts, output, manifest_output=None, tag=None):
plan_path, receipts, output = Path(plan_path), Path(receipts), Path(output)
plan = read_json(plan_path)
if manifest_output is not None and (not isinstance(tag, str) or not re.fullmatch(r"v[0-9A-Za-z._-]+", tag)):
raise ValueError("a safe exact release tag is required")
lines, published = [], []
for asset in plan["assets"]:
receipt = read_json(receipts / (asset["name"] + ".json"))
if (receipt.get("status") != "ZIP_CRC_AND_COMPLETE_PAYLOAD_SHA256_PASS"
or receipt.get("asset") != asset["name"]
or receipt.get("kind") != asset["kind"]
or receipt.get("checkpoint") != plan["snapshot"]
or receipt.get("payload_files") != len(asset["files"])
or receipt.get("plan_sha256") != digest(plan_path)
or receipt.get("payload_sha256") != {row["name"]: row["sha256"] for row in asset["files"]}
or not re.fullmatch(r"[0-9a-f]{64}", receipt.get("sha256", ""))
or not 0 < receipt.get("bytes", 0) < MAX_ASSET_BYTES):
raise ValueError("incomplete or mismatched release asset receipt")
lines.append(f"{receipt['sha256']} {asset['name']}\n")
published.append({"name": asset["name"], "kind": asset["kind"], "bytes": receipt["bytes"],
"sha256": receipt["sha256"], "files": asset["files"]})
with output.open("x", encoding="ascii") as stream:
stream.writelines(lines)
if manifest_output is not None:
write_new_json(manifest_output, {"format_version": 1, "tag": tag, "checkpoint": plan["snapshot"],
"approved_hd_frames": plan["approved_hd_frames"], "data_files": plan["data_files"],
"data_bytes": plan["data_bytes"], "bundled_mods": plan["bundled_mods"], "assets": published})
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
planning = commands.add_parser("plan")
planning.add_argument("--package", type=Path, required=True)
planning.add_argument("--expected-commit", required=True)
planning.add_argument("--output", type=Path, required=True)
exporting = commands.add_parser("archive")
exporting.add_argument("--plan", type=Path, required=True)
exporting.add_argument("--asset", required=True)
exporting.add_argument("--output", type=Path, required=True)
exporting.add_argument("--receipt", type=Path, required=True)
summing = commands.add_parser("checksums")
summing.add_argument("--plan", type=Path, required=True)
summing.add_argument("--receipts", type=Path, required=True)
summing.add_argument("--output", type=Path, required=True)
summing.add_argument("--manifest-output", type=Path, required=True)
summing.add_argument("--tag", required=True)
args = parser.parse_args()
if args.command == "plan":
value = build_plan(args.package, args.expected_commit)
write_new_json(args.output, value)
print(json.dumps({key: value[key] for key in ("snapshot", "approved_hd_frames", "bundled_mods", "data_files", "data_bytes")}))
elif args.command == "archive":
value = write_asset(args.plan, args.asset, args.output)
write_new_json(args.receipt, value)
print(json.dumps({key: value[key] for key in ("status", "asset", "bytes", "sha256", "payload_files")}))
else:
checksums(args.plan, args.receipts, args.output, args.manifest_output, args.tag)
if __name__ == "__main__":
main()
+323
View File
@@ -0,0 +1,323 @@
"""Small real-ZIP release fixtures; no production archives or game build."""
from pathlib import Path
import json
import os
import tempfile
import unittest
from unittest.mock import patch
import zipfile
import export_release as release
COMMIT = "a" * 40
class ReleaseExportTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.package = self.root / "package"
self.package.mkdir()
fixtures = {"data/INDEX": b"10000 4 misc/pic\n", "data/misc/pic": b"base",
"data/hd/jobs/reviewed/derived/picture.png": b"accepted artwork",
"data/hd/jobs/reviewed/proofs/actual-proof.json": b"{}",
"bin/raptor-rust": b"Linux exe", "bin/raptor-rust.exe": b"Windows exe",
"bin/SDL2.dll": b"Windows SDL", "lib/libSDL2-2.0.so.0": b"Linux SDL",
"run.sh": b"#!/bin/sh\n", "run.cmd": b"@echo off\n",
"mods/example/mod.toml": b'id = "example"\n',
"mods/example/art/sd.png": b"SD", "mods/example/art/hd.png": b"HD",
"mods/example/sounds/fire.wav": b"sound", "mods/example/music/track.wav": b"music",
"mods/example/art/source/draft.png": b"private source art",
"src/main.rs": b"fn main() {}", "docs/modding.md": b"API docs",
"target/cache": b"build cache", ".reference/private": b"private review"}
for name, value in fixtures.items():
self.put(name, value)
(self.package / "run.sh").chmod(0o755)
self.put_json("data/hd/hd.json", {"sequences": [{"frames": [{"file": "jobs/reviewed/derived/picture.png"}]}]})
data = {p.relative_to(self.package).as_posix(): release.digest(p)
for p in (self.package / "data").rglob("*") if p.is_file()}
self.put_json("runtime-manifest.json", {"files": data, "approved_hd_frames": 1,
"binary_sha256": release.digest(self.package / "bin/raptor-rust"),
"data_files": len(data), "data_bytes": sum((self.package / n).stat().st_size for n in data),
"catalog_entries": 1})
self.put_json("runtime-windows-manifest.json", {"files": {
n: release.digest(self.package / n) for n in ["bin/raptor-rust.exe", "bin/SDL2.dll", "run.cmd"]},
"approved_hd_frames": 1})
patch.object(release, "require_clean_workspace").start()
self.mock_commit = patch.object(release, "git_commit", return_value=COMMIT).start()
self.addCleanup(patch.stopall)
def put(self, name, value):
path = self.package / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(value)
def put_json(self, name, value):
self.put(name, (json.dumps(value) + "\n").encode())
def plan(self):
value = release.build_plan(self.package, COMMIT)
path = self.root / "plan.json"
release.write_new_json(path, value)
return path, value
def test_complete_data_mods_audio_and_platform_split(self):
_, plan = self.plan()
data, linux, windows = plan["assets"]
self.assertIn("data/hd/jobs/reviewed/derived/picture.png", {r["name"] for r in data["files"]})
self.assertIn("data/hd/jobs/reviewed/proofs/actual-proof.json", {r["name"] for r in data["files"]})
for asset in (linux, windows):
names = {r["name"] for r in asset["files"]}
self.assertTrue({"mods/example/mod.toml", "mods/example/art/sd.png", "mods/example/art/hd.png",
"mods/example/sounds/fire.wav", "mods/example/music/track.wav", "src/main.rs"} <= names)
self.assertFalse({"target/cache", ".reference/private", "mods/example/art/source/draft.png"} & names)
self.assertIn("bin/raptor-rust", {r["name"] for r in linux["files"]})
self.assertNotIn("bin/raptor-rust.exe", {r["name"] for r in linux["files"]})
self.assertNotIn("lib/libSDL2-2.0.so.0", {r["name"] for r in windows["files"]})
self.assertEqual(plan["bundled_mods"], 1)
def test_partition_covers_every_file_once_under_budget(self):
rows = [{"name": str(i), "bytes": i} for i in range(1, 7)]
groups = release.partition(rows, 7)
self.assertEqual([r for group in groups for r in group], rows)
self.assertTrue(all(sum(r["bytes"] for r in group) <= 7 for group in groups))
with self.assertRaises(ValueError):
release.partition(rows, 5)
def test_real_zip_relocation_complete_hashes_and_linux_permissions(self):
path, plan = self.plan()
relocated = self.root / "Portable Ω folder"
for asset in plan["assets"]:
archive = self.root / asset["name"]
receipt = release.write_asset(path, asset["name"], archive)
self.assertEqual(receipt["status"], "ZIP_CRC_AND_COMPLETE_PAYLOAD_SHA256_PASS")
with zipfile.ZipFile(archive) as packed:
self.assertIsNone(packed.testzip())
packed.extractall(relocated)
if asset["kind"] == "linux":
self.assertEqual((packed.getinfo("RustRaptor/run.sh").external_attr >> 16) & 0o777, 0o755)
for row in asset["files"]:
self.assertEqual(release.digest(relocated / "RustRaptor" / row["name"]), row["sha256"])
def test_modified_same_size_data_is_rejected(self):
path, plan = self.plan()
self.put("data/misc/pic", b"evil")
with self.assertRaisesRegex(ValueError, "differs from plan"):
release.write_asset(path, plan["assets"][0]["name"], self.root / plan["assets"][0]["name"])
def test_manifest_or_commit_change_rejected(self):
path, plan = self.plan()
self.mock_commit.return_value = "b" * 40
with self.assertRaisesRegex(ValueError, "changed after"):
release.write_asset(path, plan["assets"][0]["name"], self.root / plan["assets"][0]["name"])
def test_no_overwrite_or_unplanned_output_name(self):
path, plan = self.plan()
asset = plan["assets"][0]["name"]
with self.assertRaisesRegex(ValueError, "filename"):
release.write_asset(path, asset, self.root / "wrong.zip")
output = self.root / asset
output.write_bytes(b"existing export")
with self.assertRaisesRegex(ValueError, "already exists"):
release.write_asset(path, asset, output)
self.assertEqual(output.read_bytes(), b"existing export")
def test_symlink_and_fifo_sources_are_rejected_without_blocking(self):
outside = self.root / "outside"
outside.write_bytes(b"base")
source = self.package / "data/misc/pic"
source.unlink()
source.symlink_to(outside)
with self.assertRaises(OSError):
with release.opened_source(self.package, "data/misc/pic", 4):
self.fail("symlink source was accepted")
source.unlink()
os.mkfifo(source)
with self.assertRaisesRegex(ValueError, "regular file"):
with release.opened_source(self.package, "data/misc/pic", 0):
self.fail("FIFO source was accepted")
def test_open_descriptor_growth_is_rejected(self):
with self.assertRaisesRegex(ValueError, "changed while"):
with release.opened_source(self.package, "data/misc/pic", 4) as (stream, _):
with (self.package / "data/misc/pic").open("ab") as growing:
growing.write(b"extra")
self.assertEqual(stream.read(5), b"basee")
def test_checksums_require_complete_matching_actual_receipts(self):
path, plan = self.plan()
receipts = self.root / "receipts"
receipts.mkdir()
with self.assertRaises(FileNotFoundError):
release.checksums(path, receipts, self.root / "SHA256SUMS")
for asset in plan["assets"]:
result = release.write_asset(path, asset["name"], self.root / asset["name"])
release.write_new_json(receipts / (asset["name"] + ".json"), result)
release.checksums(path, receipts, self.root / "SHA256SUMS")
self.assertEqual(len((self.root / "SHA256SUMS").read_text().splitlines()), len(plan["assets"]))
record = receipts / (plan["assets"][0]["name"] + ".json")
value = release.read_json(record)
value["payload_sha256"] = {}
record.write_text(json.dumps(value))
with self.assertRaisesRegex(ValueError, "mismatched"):
release.checksums(path, receipts, self.root / "bad-sums")
def test_active_hd_missing_from_manifest_rejected(self):
self.put_json("data/hd/hd.json", {"sequences": [{"frames": [{"file": "missing.png"}]}]})
manifest = release.read_json(self.package / "runtime-manifest.json")
manifest["files"]["data/hd/hd.json"] = release.digest(self.package / "data/hd/hd.json")
manifest["data_bytes"] = sum((self.package / name).stat().st_size for name in manifest["files"])
self.put_json("runtime-manifest.json", manifest)
with self.assertRaises(FileNotFoundError):
release.build_plan(self.package, COMMIT)
def test_complete_physical_data_must_match_manifest(self):
self.put("data/forgotten.wav", b"required sound")
with self.assertRaisesRegex(ValueError, "complete physical"):
self.plan()
def test_nonportable_paths_rejected(self):
for name in ("../outside", "RustRaptor/a/../b", "RustRaptor/C:evil", "RustRaptor/a\\b", "RustRaptor/CON.txt", "RustRaptor/a."):
with self.subTest(name=name), self.assertRaises(ValueError):
release.safe_member(name)
def test_release_manifest_binds_all_payload_and_snapshot(self):
path, plan = self.plan()
receipts = self.root / "receipts"
receipts.mkdir()
for asset in plan["assets"]:
result = release.write_asset(path, asset["name"], self.root / asset["name"])
release.write_new_json(receipts / (asset["name"] + ".json"), result)
manifest = self.root / "release-manifest.json"
release.checksums(path, receipts, self.root / "SHA256SUMS", manifest, "v0.1.0-preview.test")
actual = release.read_json(manifest)
self.assertEqual(actual["checkpoint"], plan["snapshot"])
self.assertEqual(actual["tag"], "v0.1.0-preview.test")
self.assertEqual([a["files"] for a in actual["assets"]], [a["files"] for a in plan["assets"]])
def test_data_directory_symlink_is_rejected(self):
outside = self.root / "outside-data"
outside.mkdir()
(outside / "required.png").write_bytes(b"required")
(self.package / "data/linked").symlink_to(outside, target_is_directory=True)
with self.assertRaisesRegex(ValueError, "symlink"):
self.plan()
def test_mod_directory_symlink_is_rejected(self):
outside = self.root / "outside-mod"
outside.mkdir()
(outside / "mod.toml").write_text('id="linked"')
(self.package / "mods/linked").symlink_to(outside, target_is_directory=True)
with self.assertRaisesRegex(ValueError, "symlink"):
self.plan()
def test_unmanifested_data_fifo_is_rejected_without_blocking(self):
os.mkfifo(self.package / "data/fifo")
with self.assertRaisesRegex(ValueError, "non-regular"):
self.plan()
def test_skipped_private_symlinks_and_fifo_do_not_enter_release(self):
(self.package / "target/link").symlink_to(self.root, target_is_directory=True)
os.mkfifo(self.package / ".reference/fifo")
_, plan = self.plan()
self.assertFalse(any("target/" in row["name"] or ".reference/" in row["name"]
for asset in plan["assets"] for row in asset["files"]))
def test_selected_core_symlink_and_fifo_are_rejected(self):
for kind in ("symlink", "fifo"):
with self.subTest(kind=kind):
path = self.package / "mods/example/bad"
if kind == "symlink":
path.symlink_to(self.package / "src/main.rs")
else:
os.mkfifo(path)
try:
with self.assertRaisesRegex(ValueError, "symlink|non-regular"):
self.plan()
finally:
path.unlink()
def test_checksum_receipt_identity_and_invalid_tag_rejected_before_output(self):
path, plan = self.plan()
receipts = self.root / "receipts"
receipts.mkdir()
for asset in plan["assets"]:
result = release.write_asset(path, asset["name"], self.root / asset["name"])
release.write_new_json(receipts / (asset["name"] + ".json"), result)
record = receipts / (plan["assets"][0]["name"] + ".json")
original = release.read_json(record)
for field, bad in (("checkpoint", {}), ("kind", "wrong"), ("payload_files", -1)):
value = dict(original)
value[field] = bad
record.write_text(json.dumps(value))
output = self.root / (field + "-SHA256SUMS")
with self.assertRaisesRegex(ValueError, "mismatched"):
release.checksums(path, receipts, output)
self.assertFalse(output.exists())
record.write_text(json.dumps(original))
output = self.root / "invalid-tag-SHA256SUMS"
with self.assertRaisesRegex(ValueError, "release tag"):
release.checksums(path, receipts, output, self.root / "invalid-manifest.json", "../bad")
self.assertFalse(output.exists())
self.assertFalse((self.root / "invalid-manifest.json").exists())
class LinuxInstallerTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
script = Path(__file__).parents[1] / "release/Install-Linux.sh"
source = script.read_text().split("# BEGIN INSTALLER PYTHON\n", 1)[1].split("# END INSTALLER PYTHON", 1)[0]
cls.api = {"__name__": "installer_test"}
exec(compile(source, str(script), "exec"), cls.api)
def test_real_long_path_payload_hash_and_executable_mode(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
archive = root / "one.zip"
name = "data/" + ("long-component/" * 25) + "picture.png"
body = b"complete accepted payload"
with zipfile.ZipFile(archive, "w") as packed:
info = zipfile.ZipInfo("RustRaptor/" + name)
info.external_attr = 0o100755 << 16
packed.writestr(info, body)
row = {"name": name, "bytes": len(body), "sha256": release.hashlib.sha256(body).hexdigest()}
self.api["extract"](archive, root, [row])
actual = root / "RustRaptor" / name
self.assertEqual(actual.read_bytes(), body)
self.assertEqual(actual.stat().st_mode & 0o777, 0o755)
with self.assertRaises(FileExistsError):
self.api["extract"](archive, root, [row])
def test_unexpected_member_symlink_and_bad_hash_rejected(self):
for case in ("unexpected", "symlink", "hash", "traversal"):
with self.subTest(case=case), tempfile.TemporaryDirectory() as temp:
root = Path(temp)
archive = root / "one.zip"
name = "RustRaptor/file" if case != "traversal" else "RustRaptor/../escape"
with zipfile.ZipFile(archive, "w") as packed:
info = zipfile.ZipInfo(name)
info.external_attr = (0o120777 if case == "symlink" else 0o100644) << 16
packed.writestr(info, b"body")
row = {"name": "other" if case == "unexpected" else name[len("RustRaptor/"):],
"bytes": 4, "sha256": "0" * 64 if case == "hash" else release.hashlib.sha256(b"body").hexdigest()}
with self.assertRaises(ValueError):
self.api["extract"](archive, root, [row])
def test_existing_symlink_parent_is_rejected(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
outside = root / "outside"
outside.mkdir()
(root / "RustRaptor").symlink_to(outside, target_is_directory=True)
archive = root / "one.zip"
with zipfile.ZipFile(archive, "w") as packed:
packed.writestr("RustRaptor/file", b"body")
row = {"name": "file", "bytes": 4, "sha256": release.hashlib.sha256(b"body").hexdigest()}
with self.assertRaisesRegex(ValueError, "parent"):
self.api["extract"](archive, root, [row])
self.assertFalse((outside / "file").exists())
if __name__ == "__main__":
unittest.main()