diff --git a/.github/workflows/release-packaging.yml b/.github/workflows/release-packaging.yml new file mode 100644 index 00000000..6c2ef7da --- /dev/null +++ b/.github/workflows/release-packaging.yml @@ -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 diff --git a/README.md b/README.md index 5c61b8bd..8b812d51 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/docs/github-releases.md b/docs/github-releases.md new file mode 100644 index 00000000..333527f5 --- /dev/null +++ b/docs/github-releases.md @@ -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. diff --git a/release/Install-Linux.sh b/release/Install-Linux.sh new file mode 100755 index 00000000..b0438237 --- /dev/null +++ b/release/Install-Linux.sh @@ -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 diff --git a/release/Install-Windows.ps1 b/release/Install-Windows.ps1 new file mode 100644 index 00000000..047fcd86 --- /dev/null +++ b/release/Install-Windows.ps1 @@ -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 +} diff --git a/release/Test-Install-Windows.ps1 b/release/Test-Install-Windows.ps1 new file mode 100644 index 00000000..c6f7b28f --- /dev/null +++ b/release/Test-Install-Windows.ps1 @@ -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 } +} diff --git a/tools/export_release.py b/tools/export_release.py new file mode 100644 index 00000000..a946047e --- /dev/null +++ b/tools/export_release.py @@ -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() diff --git a/tools/test_export_release.py b/tools/test_export_release.py new file mode 100644 index 00000000..cfbf5de0 --- /dev/null +++ b/tools/test_export_release.py @@ -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()