diff --git a/Assets/Tests/Editor/NativeCliInstallerTests.cs b/Assets/Tests/Editor/NativeCliInstallerTests.cs index 0bcb4eef34..279f4a0a76 100644 --- a/Assets/Tests/Editor/NativeCliInstallerTests.cs +++ b/Assets/Tests/Editor/NativeCliInstallerTests.cs @@ -22,7 +22,7 @@ public class NativeCliInstallerTests [Test] public void GetInstallCommand_OnMacKeepsDispatcherCurlInstallerAvailable() { - // Verifies that editor installs use the dispatcher installer script, not npm. + // Verifies that editor installs download the dispatcher installer script and its checksum from the release, then execute it, and never fall back to npm. NativeCliInstallCommand command = NativeCliCommandBuilder.BuildRemoteInstallCommand( RuntimePlatform.OSXEditor, TestBetaCliVersion, @@ -31,19 +31,21 @@ public void GetInstallCommand_OnMacKeepsDispatcherCurlInstallerAvailable() Assert.That(command.FileName, Is.EqualTo("/bin/zsh")); Assert.That(command.Arguments, Does.Contain("-l -i -c")); - Assert.That(command.Arguments, Does.Contain($"https://raw.githubusercontent.com/hatayama/unity-cli-loop/{TestBetaReleaseTag}/scripts/install.sh")); + Assert.That(command.Arguments, Does.Contain($"https://github.com/hatayama/unity-cli-loop/releases/download/{TestBetaReleaseTag}/install.sh")); + Assert.That(command.Arguments, Does.Contain($"https://github.com/hatayama/unity-cli-loop/releases/download/{TestBetaReleaseTag}/install.sh.sha256")); Assert.That(command.Arguments, Does.Contain($"{CliConstants.POSIX_SHELL_EXECUTABLE_PATH} -c")); Assert.That(command.Arguments, Does.Contain("ULOOP_VERSION")); Assert.That(command.Arguments, Does.Contain(TestBetaReleaseTag)); Assert.That(command.Arguments, Does.Not.Contain("ULOOP_REMOVE_LEGACY")); Assert.That(command.ManualCommand, Does.Contain("curl -fsSL")); Assert.That(command.ManualCommand, Does.Not.Contain("npm")); + Assert.That(command.ManualCommand, Does.Not.Contain("raw.githubusercontent.com")); } [Test] public void GetInstallCommand_OnMacDelegatesPosixSnippetThroughSh() { - // Verifies that fish or other login shells only load environment before POSIX script execution. + // Verifies that fish or other login shells only load environment before the POSIX script downloads, verifies, and executes. NativeCliInstallCommand command = NativeCliCommandBuilder.BuildRemoteInstallCommand( RuntimePlatform.OSXEditor, TestBetaCliVersion, @@ -53,14 +55,16 @@ public void GetInstallCommand_OnMacDelegatesPosixSnippetThroughSh() Assert.That(command.FileName, Is.EqualTo("/opt/homebrew/bin/fish")); Assert.That(command.Arguments, Does.Contain("-l -i -c")); Assert.That(command.ManualCommand, Does.StartWith($"{CliConstants.POSIX_SHELL_EXECUTABLE_PATH} -c ")); - Assert.That(command.ManualCommand, Does.Contain("tmp_script=$(mktemp)")); + Assert.That(command.ManualCommand, Does.Contain("tmp_dir=$(mktemp -d)")); Assert.That(command.ManualCommand, Does.Contain("curl -fsSL")); + Assert.That(command.ManualCommand, Does.Contain("sha256sum -c install.sh.sha256")); + Assert.That(command.ManualCommand, Does.Contain("shasum -a 256 -c install.sh.sha256")); } [Test] public void GetInstallCommand_OnMacPropagatesInstallerDownloadFailure() { - // Verifies that editor installs do not report success when curl fails before script execution. + // Verifies that editor installs do not report success when curl or checksum verification fails before script execution. NativeCliInstallCommand command = NativeCliCommandBuilder.BuildRemoteInstallCommand( RuntimePlatform.OSXEditor, TestBetaCliVersion, @@ -76,7 +80,7 @@ public void GetInstallCommand_OnMacPropagatesInstallerDownloadFailure() [Test] public void GetInstallCommand_OnWindowsKeepsDispatcherPowerShellInstallerAvailable() { - // Verifies that editor installs use the dispatcher PowerShell installer script. + // Verifies that editor installs download the dispatcher PowerShell installer script and its checksum from the release, verify SHA-256, and run via -File, not `irm | iex`. NativeCliInstallCommand command = NativeCliCommandBuilder.BuildRemoteInstallCommand( RuntimePlatform.WindowsEditor, TestBetaCliVersion, @@ -84,10 +88,17 @@ public void GetInstallCommand_OnWindowsKeepsDispatcherPowerShellInstallerAvailab "/bin/zsh"); Assert.That(command.FileName, Is.EqualTo("powershell")); - Assert.That(command.Arguments, Does.Contain($"https://raw.githubusercontent.com/hatayama/unity-cli-loop/{TestBetaReleaseTag}/scripts/install.ps1")); - Assert.That(command.Arguments, Does.Contain($"$env:ULOOP_VERSION='{TestBetaReleaseTag}'")); + Assert.That(command.Arguments, Does.Contain($"https://github.com/hatayama/unity-cli-loop/releases/download/{TestBetaReleaseTag}/install.ps1")); + Assert.That(command.Arguments, Does.Contain($"https://github.com/hatayama/unity-cli-loop/releases/download/{TestBetaReleaseTag}/install.ps1.sha256")); + Assert.That(command.Arguments, Does.Contain($"$env:ULOOP_VERSION = '{TestBetaReleaseTag}'")); Assert.That(command.Arguments, Does.Not.Contain("ULOOP_REMOVE_LEGACY")); - Assert.That(command.ManualCommand, Does.Contain("irm")); + Assert.That(command.ManualCommand, Does.Contain("$ErrorActionPreference = 'Stop'")); + Assert.That(command.ManualCommand, Does.Contain("Invoke-WebRequest")); + Assert.That(command.ManualCommand, Does.Contain("Get-FileHash")); + Assert.That(command.ManualCommand, Does.Contain("-File $script_path")); + Assert.That(command.ManualCommand, Does.Not.Contain("irm")); + Assert.That(command.ManualCommand, Does.Not.Contain("| iex")); + Assert.That(command.ManualCommand, Does.Not.Contain("raw.githubusercontent.com")); Assert.That(command.ManualCommand, Does.Not.Contain("npm")); } @@ -132,7 +143,7 @@ public void GetInstallCommand_WhenVPrefixedVersionUsesDispatcherReleaseTag() Assert.That(command.Arguments, Does.Contain("dispatcher-v3.0.0")); Assert.That( command.Arguments, - Does.Contain("https://raw.githubusercontent.com/hatayama/unity-cli-loop/dispatcher-v3.0.0/scripts/install.sh")); + Does.Contain("https://github.com/hatayama/unity-cli-loop/releases/download/dispatcher-v3.0.0/install.sh")); } [Test] @@ -225,25 +236,37 @@ public void GetInstallCommand_WhenLocalPackageOnWindowsUsesPackageLocalInstaller } [Test] - public void BuildInstallerScriptUrl_WhenBetaVersionUsesReleaseInstallerScript() + public void BuildInstallerScriptUrl_WhenBetaVersionUsesReleaseInstallerAsset() { - // Verifies that beta editor installs use the script shipped with the selected dispatcher release. + // Verifies that beta editor installs point at the release-asset install.sh, matching `uloop update`'s verified download URL. string url = NativeCliCommandBuilder.BuildInstallerScriptUrl( TestBetaReleaseTag, CliConstants.POSIX_INSTALL_SCRIPT_NAME); - Assert.That(url, Is.EqualTo($"https://raw.githubusercontent.com/hatayama/unity-cli-loop/{TestBetaReleaseTag}/scripts/install.sh")); + Assert.That(url, Is.EqualTo($"https://github.com/hatayama/unity-cli-loop/releases/download/{TestBetaReleaseTag}/install.sh")); } [Test] - public void BuildInstallerScriptUrl_WhenStableVersionUsesReleaseInstallerScript() + public void BuildInstallerScriptUrl_WhenStableVersionUsesReleaseInstallerAsset() { - // Verifies that stable editor installs use the script shipped with the selected dispatcher release. + // Verifies that stable editor installs point at the release-asset install.ps1, matching `uloop update`'s verified download URL. string url = NativeCliCommandBuilder.BuildInstallerScriptUrl( TestStableReleaseTag, CliConstants.WINDOWS_INSTALL_SCRIPT_NAME); - Assert.That(url, Is.EqualTo($"https://raw.githubusercontent.com/hatayama/unity-cli-loop/{TestStableReleaseTag}/scripts/install.ps1")); + Assert.That(url, Is.EqualTo($"https://github.com/hatayama/unity-cli-loop/releases/download/{TestStableReleaseTag}/install.ps1")); + } + + [Test] + public void BuildInstallerChecksumUrl_AppendsShaSuffixToScriptUrl() + { + // Verifies the checksum URL points at the release-asset .sha256 sidecar next to the installer script. + string scriptUrl = NativeCliCommandBuilder.BuildInstallerScriptUrl( + TestBetaReleaseTag, + CliConstants.POSIX_INSTALL_SCRIPT_NAME); + string checksumUrl = NativeCliCommandBuilder.BuildInstallerChecksumUrl(scriptUrl); + + Assert.That(checksumUrl, Is.EqualTo(scriptUrl + ".sha256")); } [Test] diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs index a66485a8c6..a311eb3fcb 100644 --- a/Packages/src/Editor/Domain/CliConstants.cs +++ b/Packages/src/Editor/Domain/CliConstants.cs @@ -13,7 +13,7 @@ public static class CliConstants public const string VERSION_FLAG = "--version"; public const string SHORT_VERSION_FLAG = "-v"; public const string JSON_FLAG = "--json"; - public const string RAW_CONTENT_BASE_URL = "https://raw.githubusercontent.com/hatayama/unity-cli-loop"; + public const string RELEASE_DOWNLOAD_BASE_URL = "https://github.com/hatayama/unity-cli-loop/releases/download"; public const string SCRIPTS_DIR_NAME = "scripts"; public const string POSIX_INSTALL_SCRIPT_NAME = "install.sh"; public const string WINDOWS_INSTALL_SCRIPT_NAME = "install.ps1"; diff --git a/Packages/src/Editor/Infrastructure/CLI/NativeCliCommandBuilder.cs b/Packages/src/Editor/Infrastructure/CLI/NativeCliCommandBuilder.cs index 5698b28606..45924f6194 100644 --- a/Packages/src/Editor/Infrastructure/CLI/NativeCliCommandBuilder.cs +++ b/Packages/src/Editor/Infrastructure/CLI/NativeCliCommandBuilder.cs @@ -44,9 +44,11 @@ internal static NativeCliInstallCommand BuildInstallCommandWithPackagePath( string localScriptPath = ResolvePackageLocalInstallerScriptPath( packageResolvedPath, CliConstants.WINDOWS_INSTALL_SCRIPT_NAME); + string windowsScriptUrl = BuildInstallerScriptUrl(releaseTag, CliConstants.WINDOWS_INSTALL_SCRIPT_NAME); string command = string.IsNullOrEmpty(localScriptPath) ? BuildWindowsRemoteInstallScriptCommand( - BuildInstallerScriptUrl(releaseTag, CliConstants.WINDOWS_INSTALL_SCRIPT_NAME), + windowsScriptUrl, + BuildInstallerChecksumUrl(windowsScriptUrl), releaseTag) : BuildWindowsLocalInstallScriptCommand(localScriptPath, releaseTag); return new NativeCliInstallCommand( @@ -58,9 +60,11 @@ internal static NativeCliInstallCommand BuildInstallCommandWithPackagePath( string posixLocalScriptPath = ResolvePackageLocalInstallerScriptPath( packageResolvedPath, CliConstants.POSIX_INSTALL_SCRIPT_NAME); + string posixScriptUrl = BuildInstallerScriptUrl(releaseTag, CliConstants.POSIX_INSTALL_SCRIPT_NAME); string posixCommand = string.IsNullOrEmpty(posixLocalScriptPath) ? BuildPosixRemoteInstallScriptCommand( - BuildInstallerScriptUrl(releaseTag, CliConstants.POSIX_INSTALL_SCRIPT_NAME), + posixScriptUrl, + BuildInstallerChecksumUrl(posixScriptUrl), releaseTag) : BuildPosixLocalInstallScriptCommand(posixLocalScriptPath, releaseTag); string loginShellCommand = BuildLoginShellPosixInstallScriptCommand(posixCommand); @@ -83,15 +87,25 @@ internal static NativeCliInstallCommand BuildUninstallCommand( $"{QuoteProcessArgument(installPath)} uninstall"); } - private static string BuildPosixRemoteInstallScriptCommand(string scriptUrl, string releaseTag) + private static string BuildPosixRemoteInstallScriptCommand(string scriptUrl, string checksumUrl, string releaseTag) { UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(scriptUrl), "scriptUrl must not be null or empty"); + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(checksumUrl), "checksumUrl must not be null or empty"); UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(releaseTag), "releaseTag must not be null or empty"); - return "tmp_script=$(mktemp) && " - + "trap 'rm -f \"$tmp_script\"' EXIT && " - + $"curl -fsSL {QuotePosixShellValue(scriptUrl)} -o \"$tmp_script\" && " - + $"{CliConstants.INSTALL_VERSION_ENVIRONMENT_VARIABLE}={QuotePosixShellValue(releaseTag)} sh \"$tmp_script\""; + // Why: The whole command runs as a single /bin/sh -c string where `set -e` is not applied + // for us, so every step is && chained to fail fast when curl, checksum verification, or + // execution fails. `trap ... EXIT` ensures the temp directory is removed even on failure. + string scriptName = CliConstants.POSIX_INSTALL_SCRIPT_NAME; + return "tmp_dir=$(mktemp -d) && " + + "trap 'rm -rf \"$tmp_dir\"' EXIT && " + + $"curl -fsSL {QuotePosixShellValue(scriptUrl)} -o \"$tmp_dir/{scriptName}\" && " + + $"curl -fsSL {QuotePosixShellValue(checksumUrl)} -o \"$tmp_dir/{scriptName}.sha256\" && " + + "( cd \"$tmp_dir\" && " + + $"if command -v sha256sum >/dev/null 2>&1; then sha256sum -c {scriptName}.sha256; " + + $"elif command -v shasum >/dev/null 2>&1; then shasum -a 256 -c {scriptName}.sha256; " + + $"else echo 'sha256sum or shasum is required to verify {scriptName}' >&2; exit 1; fi ) && " + + $"{CliConstants.INSTALL_VERSION_ENVIRONMENT_VARIABLE}={QuotePosixShellValue(releaseTag)} sh \"$tmp_dir/{scriptName}\""; } private static string BuildPosixLocalInstallScriptCommand(string scriptPath, string releaseTag) @@ -108,13 +122,37 @@ internal static string BuildLoginShellPosixInstallScriptCommand(string posixComm return $"{CliConstants.POSIX_SHELL_EXECUTABLE_PATH} -c {QuotePosixShellValue(posixCommand)}"; } - private static string BuildWindowsRemoteInstallScriptCommand(string scriptUrl, string releaseTag) + private static string BuildWindowsRemoteInstallScriptCommand(string scriptUrl, string checksumUrl, string releaseTag) { UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(scriptUrl), "scriptUrl must not be null or empty"); + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(checksumUrl), "checksumUrl must not be null or empty"); UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(releaseTag), "releaseTag must not be null or empty"); - return $"$env:{CliConstants.INSTALL_VERSION_ENVIRONMENT_VARIABLE}={QuotePowerShellSingleQuotedValue(releaseTag)}; " - + $"irm {QuotePowerShellSingleQuotedValue(scriptUrl)} | iex"; + // Why: Downloading with Invoke-WebRequest to a file and re-launching PowerShell with -File + // replaces the previous `irm | iex` streaming path so the script is verified before it runs + // and so the child process owns exit-code propagation. `$ErrorActionPreference = 'Stop'` + // upgrades cmdlet non-terminating errors (e.g. missing checksum file) into throws so the + // fail-close path does not depend on incidental null dereferences downstream. The .sha256 + // file contains " ", so the leading whitespace-delimited token is + // compared as lower case against Get-FileHash's upper-case output. + string scriptName = CliConstants.WINDOWS_INSTALL_SCRIPT_NAME; + return "$tmp_dir = New-Item -ItemType Directory -Path (Join-Path $env:TEMP ([System.Guid]::NewGuid().ToString())) -Force; " + + $"$script_path = Join-Path $tmp_dir.FullName {QuotePowerShellSingleQuotedValue(scriptName)}; " + + $"$checksum_path = Join-Path $tmp_dir.FullName {QuotePowerShellSingleQuotedValue(scriptName + ".sha256")}; " + + "try { " + + "$ErrorActionPreference = 'Stop'; " + + "$ProgressPreference = 'SilentlyContinue'; " + + $"Invoke-WebRequest -UseBasicParsing -Uri {QuotePowerShellSingleQuotedValue(scriptUrl)} -OutFile $script_path; " + + $"Invoke-WebRequest -UseBasicParsing -Uri {QuotePowerShellSingleQuotedValue(checksumUrl)} -OutFile $checksum_path; " + + "$expected_hash = ((Get-Content -Raw -Encoding UTF8 $checksum_path) -split '\\s+')[0].ToLowerInvariant(); " + + "$actual_hash = (Get-FileHash -Algorithm SHA256 -Path $script_path).Hash.ToLowerInvariant(); " + + $"if ($actual_hash -ne $expected_hash) {{ throw ('Checksum mismatch for {scriptName}: expected=' + $expected_hash + ' actual=' + $actual_hash) }}; " + + $"$env:{CliConstants.INSTALL_VERSION_ENVIRONMENT_VARIABLE} = {QuotePowerShellSingleQuotedValue(releaseTag)}; " + + "& powershell -NoProfile -ExecutionPolicy Bypass -File $script_path; " + + $"if ($LASTEXITCODE -ne 0) {{ throw ('{scriptName} exited with code ' + $LASTEXITCODE) }} " + + "} finally { " + + "Remove-Item -Recurse -Force -LiteralPath $tmp_dir.FullName -ErrorAction SilentlyContinue " + + "}"; } private static string BuildWindowsLocalInstallScriptCommand(string scriptPath, string releaseTag) @@ -196,7 +234,17 @@ internal static string BuildInstallerScriptUrl(string releaseTag, string assetNa UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(releaseTag), "releaseTag must not be null or empty"); UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(assetName), "assetName must not be null or empty"); - return $"{CliConstants.RAW_CONTENT_BASE_URL}/{releaseTag}/{CliConstants.SCRIPTS_DIR_NAME}/{assetName}"; + // Why: Installer scripts are shipped as dispatcher release assets alongside their .sha256 + // sidecars, so the release download URL is used instead of raw content refs to keep the + // Unity install path in sync with `uloop update`'s verified installer download. + return $"{CliConstants.RELEASE_DOWNLOAD_BASE_URL}/{releaseTag}/{assetName}"; + } + + internal static string BuildInstallerChecksumUrl(string scriptUrl) + { + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(scriptUrl), "scriptUrl must not be null or empty"); + + return scriptUrl + ".sha256"; } } }