Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 39 additions & 16 deletions Assets/Tests/Editor/NativeCliInstallerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -76,18 +80,25 @@ 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,
false,
"/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"));
}

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion Packages/src/Editor/Domain/CliConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
70 changes: 59 additions & 11 deletions Packages/src/Editor/Infrastructure/CLI/NativeCliCommandBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
Expand All @@ -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)
Expand All @@ -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 "<hex-hash> <filename>", 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)
Expand Down Expand Up @@ -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";
}
}
}
Loading