param( [Parameter(Mandatory = $true)][string]$Root, [Parameter(Mandatory = $true)][string]$OutputDirectory ) $ErrorActionPreference = 'Stop' $rootPath = (Resolve-Path -LiteralPath $Root).Path $outputPath = [IO.Path]::GetFullPath($OutputDirectory) New-Item -ItemType Directory -Force -Path $outputPath | Out-Null Add-Type -AssemblyName System.IO.Compression Add-Type -AssemblyName System.IO.Compression.FileSystem function Get-Sha256([byte[]]$Bytes) { $sha = [Security.Cryptography.SHA256]::Create() try { return ([BitConverter]::ToString($sha.ComputeHash($Bytes)) -replace '-', '').ToLowerInvariant() } finally { $sha.Dispose() } } function Get-KeyMaterial([byte[]]$Salt, [string]$Purpose) { $purposeBytes = [Text.Encoding]::UTF8.GetBytes("YMhut.Box.AssetPackage.v2|$Purpose") $material = [byte[]]::new($purposeBytes.Length + $Salt.Length) [Array]::Copy($purposeBytes, 0, $material, 0, $purposeBytes.Length) [Array]::Copy($Salt, 0, $material, $purposeBytes.Length, $Salt.Length) $sha = [Security.Cryptography.SHA512]::Create() try { return $sha.ComputeHash($material) } finally { $sha.Dispose() } } function Write-AuthenticatedPackage([byte[]]$Plain, [string]$Purpose, [string]$Path) { $salt = [byte[]]::new(16) $iv = [byte[]]::new(16) $random = [Security.Cryptography.RandomNumberGenerator]::Create() try { $random.GetBytes($salt) $random.GetBytes($iv) } finally { $random.Dispose() } $keys = Get-KeyMaterial $salt $Purpose $aes = [Security.Cryptography.Aes]::Create() try { $aes.Key = $keys[0..31] $aes.IV = $iv $aes.Mode = [Security.Cryptography.CipherMode]::CBC $aes.Padding = [Security.Cryptography.PaddingMode]::PKCS7 $encryptor = $aes.CreateEncryptor() try { $cipher = $encryptor.TransformFinalBlock($Plain, 0, $Plain.Length) } finally { $encryptor.Dispose() } } finally { $aes.Dispose() } $magic = [Text.Encoding]::ASCII.GetBytes('YMHUTAS2') $header = [IO.MemoryStream]::new() $writer = [IO.BinaryWriter]::new($header, [Text.Encoding]::UTF8, $true) try { $writer.Write($magic) $writer.Write([byte]2) $writer.Write($salt) $writer.Write($iv) $writer.Write([long]$Plain.Length) $writer.Write([long]$cipher.Length) $writer.Flush() } finally { $writer.Dispose() } $signed = [byte[]]::new($header.Length + $cipher.Length) [Array]::Copy($header.ToArray(), 0, $signed, 0, $header.Length) [Array]::Copy($cipher, 0, $signed, $header.Length, $cipher.Length) $hmac = [Security.Cryptography.HMACSHA256]::new($keys[32..63]) try { $tag = $hmac.ComputeHash($signed) } finally { $hmac.Dispose() } [IO.File]::WriteAllBytes($Path, $signed + $tag) $header.Dispose() } function Add-SourceFile([hashtable]$Entries, [string]$Source, [string]$EntryName) { if (-not (Test-Path -LiteralPath $Source -PathType Leaf)) { throw "Missing authenticated asset source: $Source" } $normalized = $EntryName.Replace('\', '/').TrimStart('/') if ($normalized -eq '' -or $normalized.Contains('../') -or $normalized.StartsWith('../')) { throw "Unsafe authenticated asset entry: $EntryName" } if ($Entries.ContainsKey($normalized)) { throw "Duplicate authenticated asset entry: $normalized" } $bytes = [IO.File]::ReadAllBytes($Source) $Entries[$normalized] = [pscustomobject]@{ Path = $normalized; Bytes = $bytes; Sha256 = Get-Sha256 $bytes } } function Add-SourceTree([hashtable]$Entries, [string]$SourceRoot, [string]$Prefix, [scriptblock]$Include = $null) { if (-not (Test-Path -LiteralPath $SourceRoot -PathType Container)) { throw "Missing authenticated asset directory: $SourceRoot" } foreach ($file in Get-ChildItem -LiteralPath $SourceRoot -Recurse -File) { $relative = $file.FullName.Substring($SourceRoot.TrimEnd('\', '/').Length).TrimStart('\', '/').Replace('\', '/') if ($Include -and -not (& $Include $relative)) { continue } Add-SourceFile $Entries $file.FullName (("$Prefix/$relative").TrimStart('/')) } } function New-AssetPackage([hashtable]$Entries, [string]$BundleId, [string]$Purpose, [string]$Path) { $memory = [IO.MemoryStream]::new() $archive = [IO.Compression.ZipArchive]::new($memory, [IO.Compression.ZipArchiveMode]::Create, $true) try { foreach ($item in ($Entries.Values | Sort-Object Path)) { $entry = $archive.CreateEntry($item.Path, [IO.Compression.CompressionLevel]::Optimal) $stream = $entry.Open() try { $stream.Write($item.Bytes, 0, $item.Bytes.Length) } finally { $stream.Dispose() } } $manifest = [ordered]@{ schemaVersion = 2 bundleId = $BundleId files = @($Entries.Values | Sort-Object Path | ForEach-Object { [ordered]@{ path = $_.Path; length = $_.Bytes.Length; sha256 = $_.Sha256 } }) } $manifestBytes = [Text.Encoding]::UTF8.GetBytes(($manifest | ConvertTo-Json -Depth 5 -Compress)) $manifestEntry = $archive.CreateEntry('_manifest.json', [IO.Compression.CompressionLevel]::Optimal) $manifestStream = $manifestEntry.Open() try { $manifestStream.Write($manifestBytes, 0, $manifestBytes.Length) } finally { $manifestStream.Dispose() } } finally { $archive.Dispose() } $plain = $memory.ToArray() $memory.Dispose() Write-AuthenticatedPackage $plain $Purpose $Path } $runtimeEntries = @{} $projectAssets = Join-Path $rootPath 'src\box-winUI\Assets' $repoAssets = Join-Path $rootPath 'assets' Add-SourceTree $runtimeEntries (Join-Path $projectAssets 'startup-splash') 'startup-splash' Add-SourceTree $runtimeEntries (Join-Path $projectAssets 'home-globe') 'home-globe' { param($relative) return $relative -notlike 'textures/solar/source/*' -and $relative -notlike 'textures/solar/optimized/*' -and $relative -ne 'textures/solar/solar-texture-manifest.json' } Add-SourceTree $runtimeEntries (Join-Path $projectAssets 'tool-pages') 'tool-pages' Add-SourceTree $runtimeEntries (Join-Path $projectAssets 'tool-results') 'tool-results' Add-SourceTree $runtimeEntries (Join-Path $repoAssets 'icons') 'icons' { param($relative) return $relative -ne '.gitkeep' } Add-SourceTree $runtimeEntries (Join-Path $repoAssets 'images') 'images' { param($relative) return $relative -ne 'loading.gif' } Add-SourceFile $runtimeEntries (Join-Path $repoAssets 'download.svg') 'download.svg' Add-SourceFile $runtimeEntries (Join-Path $repoAssets 'icons\app_icon.ico') 'app_icon.ico' New-AssetPackage $runtimeEntries 'runtime-assets' 'runtime-assets' (Join-Path $outputPath 'runtime-assets.dat') $dataEntries = @{} $dataRoot = Join-Path $repoAssets 'data' foreach ($file in Get-ChildItem -LiteralPath $dataRoot -Recurse -File) { if ($file.Extension -ieq '.dat' -or $file.Name -ieq 'ymhut-data.ybin') { continue } $relative = $file.FullName.Substring($repoAssets.TrimEnd('\', '/').Length).TrimStart('\', '/').Replace('\', '/') Add-SourceFile $dataEntries $file.FullName $relative } $rankingRoot = Join-Path $rootPath 'server\unified-management\internal\reference\data' if (Test-Path -LiteralPath $rankingRoot -PathType Container) { foreach ($file in Get-ChildItem -LiteralPath $rankingRoot -Filter '*.json' -File) { Add-SourceFile $dataEntries $file.FullName ("data/rankings/$($file.Name)") } } New-AssetPackage $dataEntries 'reference-data' 'reference-data' (Join-Path $outputPath 'ymhut-data.ybin')