+ # The /releases/latest/download/ redirect rather than the API: unauthenticated\r
+ # API calls are rate-limited to 60/hour per IP, which a provisioning run behind a\r
+ # shared NAT can genuinely exhaust, and the redirect costs none of that budget.\r
+ # To hold a box on a known build, pin the tag instead:\r
+ # .../releases/download/v3.5.0-gABCDEF0/$RsyncAsset\r
+ $RsyncUrl = "https://github.com/$RsyncRepo/releases/latest/download/$RsyncAsset"\r
+ $RsyncDir = 'C:\Tools\rsync'\r
+ try {\r
+ New-Item -ItemType Directory -Force -Path $RsyncDir | Out-Null\r
+ $RsyncExe = Join-Path $RsyncDir 'rsync.exe'\r
+\r
+ # Does the release's ssh.exe have the libcrypto it needs? Decided before the\r
+ # download so the answer can also gate what comes out of the zip. This is a\r
+ # cheap pre-filter only - the authoritative check is running the thing, which\r
+ # happens after the unpack below.\r
+ $SysCrypto = Join-Path $env:WINDIR 'System32\libcrypto.dll'\r
+ $WantSsh = Test-Path $SysCrypto\r
+ if (-not $WantSsh) {\r
+ Write-Warning "$SysCrypto is missing - the OpenSSH Client capability is not installed - and the release's ssh.exe needs it. Installing rsync.exe only; rsync will use the ssh on the PATH."\r
+ } else {\r
+ $v = (Get-Item $SysCrypto).VersionInfo.FileVersion\r
+ if ($v -and ([version]($v -replace '[^0-9.]', '')) -lt [version]'3.8.2') {\r
+ Write-Warning "$SysCrypto is LibreSSL $v; the release's ssh.exe is built against 3.8.2 (Windows OpenSSH Client 9.5). Update Windows, or expect ssh.exe not to start."\r
+ }\r
+ # No architecture special-case here any more: the asset chosen above\r
+ # matches the host, so the release's ssh.exe and System32's libcrypto.dll\r
+ # are the same architecture on every box. The run-it check below still\r
+ # happens on all of them - it is cheap, and it is what caught the ARM64\r
+ # mismatch back when this took the x64 zip.\r
+ }\r
+\r
+ # Download and unpack beside the targets, not over them, so an interrupted\r
+ # transfer can't leave a truncated rsync.exe sitting on the PATH. The scratch\r
+ # name still has to END in .zip: Windows PowerShell 5.1's Expand-Archive\r
+ # refuses any other extension outright ("*.download is not a supported\r
+ # archive file format"), where PowerShell 7 just reads the file.\r
+ [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\r
+ $tmpZip = Join-Path $RsyncDir "download-$RsyncAsset"\r
+\r
+ # The .sha256 published beside the zip is fetched FIRST, for two jobs. It\r
+ # verifies the download below. Same origin, so that is an integrity check on\r
+ # the transfer rather than a defence against a hostile release - but a\r
+ # truncated or proxy-mangled download is the failure that actually happens,\r
+ # and it fails here instead of mid-transfer later.\r
+ #\r
+ # And it says whether there is anything new to fetch at all. The hash of the\r
+ # zip the installed files came from is recorded beside them in\r
+ # rsync-windows.sha256, written only once they have all landed (as BinSkim's\r
+ # nupkg-version.txt is). When the release's hash still matches and the files\r
+ # are all there, the download and unpack are skipped - a re-run costs one\r
+ # small request instead of the zip. An ssh.exe that was wanted but is gone\r
+ # (removed below for not starting) does NOT count as there, so each run\r
+ # tries it again: whatever stopped it starting may have been fixed since.\r
+ #\r
+ # -OutFile, not .Content: GitHub serves the .sha256 as\r
+ # application/octet-stream, and Invoke-WebRequest hands back a byte[] rather\r
+ # than a string for any non-text content type, so .Content would compare the\r
+ # first BYTE against the hash and fail on every correct download.\r
+ $tmpSha = "$tmpZip.sha256"\r
+ Invoke-WebRequest -Uri "$RsyncUrl.sha256" -OutFile $tmpSha -UseBasicParsing\r
+ $want = (((Get-Content $tmpSha -Raw) -split '\s+')[0]).Trim().ToLower()\r
+ Remove-Item $tmpSha -Force -ErrorAction SilentlyContinue\r
+\r
+ $RsyncMarker = Join-Path $RsyncDir 'rsync-windows.sha256'\r
+ $InstalledSha = if (Test-Path $RsyncMarker) { (Get-Content $RsyncMarker -Raw).Trim().ToLower() } else { '' }\r
+ $RsyncCurrent = $want -and ($InstalledSha -eq $want) -and (Test-Path $RsyncExe) -and\r
+ (-not $WantSsh -or (Test-Path (Join-Path $RsyncDir 'ssh.exe')))\r
+ if ($RsyncCurrent) {\r
+ Write-Host " $RsyncAsset unchanged since it was installed (SHA-256 $want); not downloading it again."\r
+ } else {\r
+ Invoke-WebRequest -Uri $RsyncUrl -OutFile $tmpZip -UseBasicParsing\r
+ Write-Host " Downloaded $RsyncAsset ($([math]::Round((Get-Item $tmpZip).Length / 1MB, 2)) MB)"\r
+ $got = (Get-FileHash $tmpZip -Algorithm SHA256).Hash.ToLower()\r
+ if ($want -and $want -ne $got) {\r
+ Remove-Item $tmpZip -Force\r
+ throw "SHA-256 mismatch for ${RsyncAsset}: expected $want, got $got"\r
+ }\r
+ Write-Host " SHA-256 verified: $got"\r
+\r
+ # Unpack to a scratch directory and move out the files we asked for, rather\r
+ # than expanding straight over the install directory: the zip is the unit\r
+ # that was checksummed, and this way a future release adding something to it\r
+ # cannot quietly drop that something onto the machine PATH.\r
+ $unpack = Join-Path $RsyncDir '.unpack'\r
+ if (Test-Path $unpack) { Remove-Item -Recurse -Force $unpack }\r
+ Expand-Archive -Path $tmpZip -DestinationPath $unpack -Force\r
+ Remove-Item $tmpZip -Force\r
+ foreach ($f in 'rsync.exe', 'ssh.exe', 'COPYING.txt', 'NOTICE-ssh.txt') {\r
+ $src = Join-Path $unpack $f\r
+ if (-not (Test-Path $src)) { continue }\r
+ if ($f -eq 'ssh.exe' -and -not $WantSsh) { continue }\r
+ Move-Item -Path $src -Destination (Join-Path $RsyncDir $f) -Force\r
+ }\r
+ Remove-Item -Recurse -Force $unpack\r
+ # Written last: the marker must only claim a release that fully landed.\r
+ if ($want) { Set-Content -Path $RsyncMarker -Value $want -Encoding ascii }\r